@rackbops/ac-plugin-contract 2.0.0-alpha.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AUTHORING.md ADDED
@@ -0,0 +1,934 @@
1
+ # Writing an artifact-console plugin
2
+
3
+ A plugin adds to the console without changing it: a nav **panel**, an Overview **card**, a confirm-gated
4
+ **action**, a scheduled **job**, an HTTP **route**, and a **setting** are all contributed from data + a small
5
+ server module. The host validates the plugin's manifest before it runs any of the plugin's code, loads it at
6
+ startup, and surfaces it at `GET /api/plugins`.
7
+
8
+ This guide is written against the reference plugin, [`plugins/hello`](../../plugins/hello), which contributes
9
+ **one of each** of the six points (two jobs: the host job `hello.tick` and the agent job `hello.machines`, #51;
10
+ two actions, `hello.ping` and the option-picker example `hello.order`, #463, whose choices come from a second
11
+ route, `GET /toppings`).
12
+ Every **code** snippet below is excerpted from that plugin, trimmed to what its section is about — the §1
13
+ manifest and §2 server snippets show its first action (`hello.ping`) and route (`/ping`), and the option-picker
14
+ part of §2 shows `hello.order` and `GET /toppings` — copy it and change
15
+ the names. (The §1 `package.json` is shown in two blocks — the `acPlugin` manifest, then the surrounding fields
16
+ — with the manifest's arrays on single lines for readability.) The plugin contract itself is specified in
17
+ [`design.md` §4](../plans/v2/design.md).
18
+
19
+ ## 0. Where a plugin lives
20
+
21
+ A plugin is a directory `plugins/<id>/` — a pnpm workspace member (the `plugins/*` glob in
22
+ `pnpm-workspace.yaml` already covers it, so a new dir needs no workspace edit). The host **auto-discovers** every
23
+ subdirectory of `plugins/` whose `package.json` carries an `acPlugin` block; there is no registration list to
24
+ add yourself to. A directory with no `acPlugin` block is simply not a plugin and is skipped. So the whole job is:
25
+ create `plugins/<id>/`, give its `package.json` an `acPlugin` block and the files it points at, and the host
26
+ finds it.
27
+
28
+ ## 1. The manifest — `package.json` `acPlugin`
29
+
30
+ The host reads the `acPlugin` block from the plugin's `package.json` and validates it against the generated
31
+ JSON Schema (`packages/contract/schema.json`) **before loading any code**. An invalid manifest is recorded as
32
+ an error and the plugin is skipped — it never runs. The schema is `additionalProperties: false`, so a mistyped
33
+ key is itself a hard validation error, not a silently-ignored field.
34
+
35
+ ```json
36
+ "acPlugin": {
37
+ "id": "hello",
38
+ "hostApiVersion": 1,
39
+ "kind": "in-process",
40
+ "server": "dist/server.js",
41
+ "frontend": "dist/ui.js",
42
+ "contributes": {
43
+ "panels": [{ "id": "hello-panel", "title": "Hello", "route": "/hello", "export": "HelloPanel" }],
44
+ "cards": [{ "id": "hello-card", "title": "Hello", "sizes": ["2x1"], "export": "HelloCard" }],
45
+ "actions": [
46
+ {
47
+ "id": "hello.ping",
48
+ "title": "Ping",
49
+ "scope": "global",
50
+ "confirm": true,
51
+ "fields": [{ "name": "message", "label": "Message", "type": "string" }]
52
+ }
53
+ ],
54
+ "jobs": [
55
+ { "id": "hello.tick", "schedule": "*/15 * * * *", "timeoutSec": 30, "runOn": "host" },
56
+ { "id": "hello.machines", "schedule": "17 3 * * *", "timeoutSec": 30, "runOn": "agent" }
57
+ ],
58
+ "routes": [{ "method": "GET", "path": "/ping" }],
59
+ "settings": [{ "key": "greeting", "type": "string", "default": "hello" }],
60
+ "attention": true
61
+ }
62
+ }
63
+ ```
64
+
65
+ That `acPlugin` block sits inside an otherwise ordinary `package.json`. The **rest** of the file matters too —
66
+ the workspace ratchet requires a `name`, a `build` script, and an `exports` (or `main`) entry, so a plugin
67
+ missing any of them fails `pnpm run check`. The complete shape (the `acPlugin` block above elided):
68
+
69
+ ```jsonc
70
+ {
71
+ "name": "@ac/plugin-hello",
72
+ "version": "0.1.0",
73
+ "private": true,
74
+ "type": "module",
75
+ "exports": { ".": { "default": "./dist/server.js" } },
76
+ "acPlugin": { /* the block shown above */ },
77
+ "scripts": {
78
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.ui.json --noEmit",
79
+ "build": "tsc && tsc -p tsconfig.ui.json",
80
+ "test": "vitest run"
81
+ },
82
+ "dependencies": {
83
+ "@ac/contract": "workspace:*",
84
+ "@rackbops/ui-react": "^0.2.36",
85
+ "react": "^19.2.0"
86
+ },
87
+ "devDependencies": {
88
+ "@types/node": "catalog:",
89
+ "@types/react": "catalog:",
90
+ "typescript": "catalog:",
91
+ "vitest": "catalog:"
92
+ }
93
+ }
94
+ ```
95
+
96
+ `"type": "module"` (the emitted `dist/*.js` are ESM), `"exports"` pointing at the built server, and the shared
97
+ dev deps pinned through the workspace `catalog:` (one version of `typescript`/`vitest`/`@types/node`/`@types/react`
98
+ across the repo — run `pnpm install` after adding them). `@rackbops/ui-react` and `react` are only needed if you
99
+ ship a frontend (§3). If that frontend needs `@types/react-dom` too (a real render test, not just
100
+ `@testing-library/react`), put it on the catalog rather than a bare semver range — pnpm can otherwise link a new
101
+ consumer's `@types/react-dom` to a peer-unqualified store path that doesn't exist, failing with `TS2307` (#205).
102
+ The `scripts` and tsconfig split are explained in §3.
103
+
104
+ Rules the host enforces, at validation or load time:
105
+
106
+ - **`id`** must match `/^[a-z][a-z0-9-]*$/` and is **permanent** — it is the plugin's route namespace
107
+ (`/api/x/<id>/…`), its state key and its log prefix. Renaming it orphans a deployed plugin's stored data, so
108
+ never rename it. Plugin `id`, panel `id`/`route`, card `id` and action `id` are **globally unique** across all
109
+ installed plugins. (These, plus `hostApiVersion` equality, are checked by the *validator* before any plugin
110
+ code loads.) **Collisions are origin-aware** (#451): an installed plugin (one found under the operator's
111
+ second, agent-writable plugins directory, `AC_INSTALLED_PLUGINS_DIR`) whose `id` or any contributed
112
+ id/route matches a *bundled* plugin's (one shipped in the image's own `plugins/`) is refused — only the
113
+ installed plugin errors, naming the bundled owner — and the bundled one keeps running unaffected. This
114
+ is what stops a third-party package from disabling a bundled plugin (e.g. the auth provider, or `admin`
115
+ itself) just by declaring its id. Two bundled plugins sharing an id is still a build/CI mistake and both
116
+ error, the same for two installed plugins with no bundled plugin in the collision.
117
+ - **`hostApiVersion`** must equal the host's `HOST_API_VERSION` (currently `1`); a mismatch is rejected.
118
+ - **`kind: "in-process"`** must declare a `server` entry — the *loader* records an error and skips a plugin that
119
+ doesn't (the schema itself requires only `id`, `hostApiVersion` and `kind`). `contributes` and every sub-field
120
+ is optional in the schema — contribute only the points you use; each contribution's own required fields are
121
+ schema-enforced.
122
+ - The manifest is the **declaration**; your server code supplies the runtime halves. An HTTP route and a job must
123
+ be *declared here* or the host will refuse to serve/run them (see §2).
124
+
125
+ ## 2. The server — `src/server.ts`
126
+
127
+ The server entry (`dist/server.js`) exports **`createPlugin`**. `createPlugin(host)` must be **pure** — it does
128
+ no host I/O, so the host can construct every plugin before any side effect. All I/O goes inside `activate()`
129
+ (called once at startup) and `runJob(ctx)` (called per scheduled/triggered job run).
130
+
131
+ ```ts
132
+ import type { CreatePlugin } from "@ac/contract"
133
+
134
+ export const createPlugin: CreatePlugin = (host) => ({
135
+ activate() {
136
+ // GET /api/x/hello/ping → the greeting setting (its default comes from the manifest `settings[]`).
137
+ host.routes.register("GET", "/ping", async () => {
138
+ const greeting = (await host.settings.get("greeting")) ?? "hello"
139
+ return Response.json({ greeting })
140
+ })
141
+ // A confirm-gated action: the button, confirm and fields come from the manifest; the handler is here.
142
+ host.actions.register({
143
+ id: "hello.ping",
144
+ run: async (input) => ({ echoed: input }),
145
+ })
146
+ },
147
+ runJob(ctx) {
148
+ // One `runJob` dispatches all the plugin's contributed jobs, selected by `ctx.jobId`.
149
+ if (ctx.jobId === "hello.tick") ctx.log("hello tick")
150
+ // hello.machines (#51) calls no verb -- the per-machine fan-out itself is what it demonstrates;
151
+ // #52's real scans are the verb-calling agent jobs. Records nothing on a console with no paired
152
+ // machine, same as any other agent job dispatched to zero machines.
153
+ if (ctx.jobId === "hello.machines") {
154
+ ctx.log(`hello from the host for ${ctx.machine?.name ?? "(no machine)"}`)
155
+ }
156
+ },
157
+ })
158
+ ```
159
+
160
+ - **Route** — `host.routes.register(method, path, handler)`. The handler is a web-standard
161
+ `(request: Request) => Response | Promise<Response>`, mounted at `/api/x/<id><path>`. You may only register a
162
+ `(method, path)` you declared in `contributes.routes` — an undeclared route throws. **Never answer `502` or
163
+ `504` yourself** (#474): the production console is reached through Cloudflare, and Cloudflare's own
164
+ documentation says it "returns a Cloudflare-branded HTTP 502 or 504 error when your origin web server responds
165
+ with a standard HTTP 502 bad gateway or 504 gateway timeout error" — so on the production console (default
166
+ Cloudflare error pages) your JSON `{ ok: false, error }` does not reach the browser and a client sees an HTML
167
+ page. For "a dependency I relay to (a paired agent, the sidecar)
168
+ is unavailable or refused" answer `503`: Cloudflare's Custom Errors page says its Error Pages "do not apply to
169
+ responses with an HTTP status code of `500`, `501`, `503`, or `505`", and its "Error 503" page tells an origin's
170
+ own 503 from Cloudflare's by whether the page's HTML contains `cloudflare`. Neither says in so many words that
171
+ an origin's 503 *body* passes through — that is inferred, and a Custom Error Rule an operator sets on the zone
172
+ can act on any status from 400 up — so the check on the deployed console is what proves it. Otherwise a 4xx or
173
+ `500`. `packages/host/src/no-gateway-status.test.ts` scans every bundled plugin's `src/` and the host's, and
174
+ fails naming the file and line (an installed third-party plugin is not covered).
175
+ - **Action** — `host.actions.register({ id, run })`. The button, `confirm`/`danger` and the modal `fields` all
176
+ come from the manifest; here you supply only `run(input)`. (The action's manifest metadata drives the shell
177
+ UI; registering a `run` for an id the manifest omits simply leaves it without a button.)
178
+ - **Job** — implement `runJob(ctx)` and dispatch on `ctx.jobId`. The host runs only jobs you declared in
179
+ `contributes.jobs`, on their 5-field cron `schedule`. `ctx.log` appends to that run's recorded log;
180
+ `ctx.signal` aborts at `timeoutSec`. `runOn: "host"` runs your `runJob` once, in the host process.
181
+ `runOn: "agent"` (#51) also runs your `runJob` in the host process — the agent itself runs no plugin
182
+ code — but once per currently paired machine, with `ctx.machine` set to `{ id, name }`; do the
183
+ machine's own work through `host.agent.call(ctx.machine.id, …)`. A machine that doesn't answer when
184
+ the job is due is never a failed run: it's queued (a red `queued` row in the tasks drawer) and the
185
+ host's own scheduler tick drains the queue, running the job for that machine as soon as it becomes
186
+ reachable again — nothing in your `runJob` needs to know this happened. `host.jobs.machineRuns(jobId)`
187
+ reads the current per-machine state: one entry per paired machine for an agent job (its last run,
188
+ or why it's still queued), or a single entry for a host job. `plugins/scans` (#258) is the reference
189
+ for a verb-calling agent job: it calls a scan verb per machine and stores the answer only on
190
+ success, keeping a separate `.../error` key for a failure and throwing the agent's reason verbatim
191
+ so the run is honestly recorded failed -- store, log, throw, in that order, never masking the
192
+ agent's text with a storage error of your own.
193
+ - **Setting** — read/write with `host.settings.get(key)` / `host.settings.set(key, value)`. Both are async;
194
+ `get` returns the manifest `default` when unset.
195
+
196
+ `createPlugin` must import from `@ac/contract` **type-only** (`import type`), so the compiled `dist/server.js`
197
+ has no runtime imports — see §6.
198
+
199
+ ### Rejecting a bad action input — `ActionInputError` (#440)
200
+
201
+ An action's `run(input)` receives whatever the caller POSTed under `{ "input": … }`. When that input is
202
+ **malformed** (a missing or wrong-typed field, a bad `owner/name`), throw an `Error` whose **`name` is
203
+ `"ActionInputError"`** and the host answers `400` with its message. Any other thrown error is a `500`.
204
+ Carry the class in your own plugin — five lines, no runtime import from the host:
205
+
206
+ ```ts
207
+ import type { ActionInputErrorName } from "@ac/contract"
208
+
209
+ export class ActionInputError extends Error {
210
+ override name: ActionInputErrorName = "ActionInputError"
211
+ }
212
+ ```
213
+
214
+ Throw it for the **shape of what the caller sent** — that is the caller's to fix. Keep a plain `Error` (a 500)
215
+ for anything that went wrong in the world while acting on a well-formed input: a GitHub failure, a store that
216
+ isn't cloned, a machine that isn't paired. When unsure, leave it a plain `Error`.
217
+
218
+ **Why a name and not a class you import.** A plugin server imports `@ac/contract` as `import type` only (above), so
219
+ a class exported from the contract would give every plugin a *runtime* dependency on it, which its compiled
220
+ `dist/server.js` is meant not to have. And `instanceof` cannot see across a bundle: a rollup-bundled server
221
+ inlines its own copy of any class it declares, which is a different class from the host's. A `name` compares
222
+ equal wherever the error was made (the `AbortError` idiom). Only the *type* of the name,
223
+ `ActionInputErrorName`, comes from the contract, so renaming it there breaks your build rather than silently
224
+ turning your 400s into 500s.
225
+
226
+ **The route also guards a missing `input` for you.** If your manifest declares `fields` for the action and the
227
+ body has no `input` object (`null`, an array, a string, or nothing at all — a caller who sent the fields at the
228
+ top level of the body instead of under `input`), the host answers `400` *before your `run` is called*, naming
229
+ the fields. An action that declares no `fields` is not guarded — it is legitimately called with no input — so
230
+ its `run` must still cope with `input` being `undefined`. A field-declaring action's `run` still validates each
231
+ field itself: the guard only ensures there is an object to read them from.
232
+
233
+ ### Action form fields — `multiselect`, `hidden`, `optionsFrom` (#463)
234
+
235
+ An action's `fields[]` are what the universal modal renders. Each field has a `name` (the key in the `input` your
236
+ `run` receives), a `label`, and a `type`:
237
+
238
+ | `type` | Renders | Value your `run` receives |
239
+ |---|---|---|
240
+ | `string` | a text box | a string |
241
+ | `text` | a multi-line text box | a string |
242
+ | `number` | a number box | a number |
243
+ | `boolean` | a checkbox | a boolean |
244
+ | `select` | a `<select>` of its fixed `options` (or of the choices `optionsFrom` answers) | a string |
245
+ | `multiselect` | a picker (below) of its `optionsFrom` choices or its fixed `options`, or a comma-separated text box when it has neither | a **`string[]`**, trimmed and de-duplicated |
246
+ | `hidden` | nothing | the value the caller seeded (`actions.open({ …, input })`), unchanged; when the seed does not name it (or there is no `input`), the one in the stored draft; `""` otherwise |
247
+
248
+ A `multiselect`'s value is always an array in the modal's state, in the draft it stores, and in the `input` it
249
+ POSTs. A seed or an older stored draft that holds a comma-separated string (`"bug, docs"`) is read as that list,
250
+ so a caller may seed either form. A `hidden` field is for a value the caller knows and the user should not edit
251
+ (an id, a scope) but your `run` still needs; it is not validated or shown by the shell, so treat it as caller
252
+ input like any other.
253
+
254
+ **`optionsFrom`** lets a field's choices come from one of your own routes instead of being fixed in the manifest.
255
+ It is valid only on a `select` or `multiselect` field, not beside a non-empty fixed `options` list (one source of
256
+ choices, not two), and the validator refuses anything provably wrong at load time (the plugin is skipped, naming
257
+ the field):
258
+
259
+ ```json
260
+ {
261
+ "name": "toppings",
262
+ "label": "Toppings",
263
+ "type": "multiselect",
264
+ "optionsFrom": { "route": "/toppings", "params": ["shop"] }
265
+ }
266
+ ```
267
+
268
+ - **`route`** must be one of the plugin's **own declared `GET` routes** (`contributes.routes[]`): a path starting
269
+ with `/`, with no query string or fragment and no `..`, `//`, `%` or `\` (a percent-escaped dot segment would
270
+ climb out of your namespace once the browser resolves the URL). A route declared as `toppings` is served as
271
+ `/toppings`, so `optionsFrom` names it that way. The shell fetches `/api/x/<id><route>`.
272
+ - **`params`** (optional) names **other fields of the same action**. Their current values are sent as query
273
+ parameters (`?shop=…`), a `multiselect`'s as its comma-joined picks. While any named field is empty nothing is
274
+ fetched and the picker says `Fill in “<label>” to load the choices.` (a `hidden` field named here must be
275
+ seeded by the caller, since it has no control to fill in); the first fetch runs at once, each later one waits
276
+ for a 250 ms pause after the last change, and an answer that is no longer the latest is dropped. While the
277
+ choices are loading or blocked, the field lists the picks it still holds (`Chosen: …`), since they are still
278
+ submitted.
279
+
280
+ **What the route answers** is an **`ActionOptionsResponse`** (`packages/contract/src/action-options.ts`, its
281
+ comments condensed here):
282
+
283
+ ```ts
284
+ type ActionOptionsResponse =
285
+ | { ok: true; groups: ActionOptionGroup[]; allowCustom?: boolean; preselect?: string[] }
286
+ | { ok: false; error: string }
287
+
288
+ interface ActionOptionGroup {
289
+ key: string
290
+ caption?: string // the group's heading
291
+ exclusive?: boolean // at most one pick from this group: a segmented control with a "None"
292
+ options: ActionOption[]
293
+ }
294
+ interface ActionOption {
295
+ value: string // what is submitted
296
+ label?: string // what is shown; defaults to value
297
+ color?: string // six hex digits, no "#" -- GitHub's form (a label's colour)
298
+ description?: string // the chip's tooltip
299
+ badge?: string // a small tag beside the label
300
+ }
301
+ ```
302
+
303
+ The shell makes every part of the answer safe before it renders it: a `color` that is not exactly six hex digits
304
+ is ignored, an option `value` is trimmed, a duplicate `value` within a group, an option with no string (or a blank)
305
+ `value` and a group left empty are dropped, and every string is rendered as text, never as markup. A body that is
306
+ not that shape, a network error, no answer within 15 seconds, or `{ ok: false, error }` (**whatever the status** —
307
+ answer `503`, never `502`/`504`, per §2) is a **failed load**:
308
+ the field falls back to a plain text box, so the action stays usable: a `multiselect`'s is comma-separated
309
+ (`Couldn't load the choices: <reason>. Type them below, separated by commas.`), a `select`'s takes one value
310
+ (`Couldn't load the choices: <reason>. Type a value below.`).
311
+
312
+ The picker itself is the shell's, and behaves the way 1.x's label picker did: an `exclusive` group is a segmented
313
+ control and clicking the current pick (or "None") clears it; the other groups are coloured chips (text colour
314
+ chosen for contrast); a search box appears when **more than 8** options sit outside the exclusive groups (an
315
+ exclusive group is a small control and is never filtered) and never hides an option that is already chosen (an
316
+ option of a loaded group that you just unticked stays on screen until the filter next changes); a chosen value no group names (a seed, a
317
+ draft) lands in a trailing **Other** group instead of being dropped, and **`allowCustom: true`** adds a free-form
318
+ "add" box there (Enter adds, it does not submit the form); **`preselect`** picks those values once (a `select`
319
+ takes the first), only while nothing is chosen and the user has not touched the picker — a seed or a draft that
320
+ already holds picks wins over it (an empty draft does not). It is keyboard-operable (the chips and segments are
321
+ native checkboxes and radios), and a group is named by its `caption`, or by the field's label when it has none.
322
+ A `multiselect`'s fixed `options` list gets the same treatment as a route's answer (values and labels trimmed,
323
+ blanks and repeats dropped); a plain `select`'s fixed `options` are rendered as written.
324
+
325
+ `plugins/hello` is the worked example: `hello.order` (a `hidden` `ticket`, a `string` `shop` and the `multiselect`
326
+ `toppings` above) reads `GET /toppings?shop=…`, which answers an exclusive **Size** group and a coloured
327
+ **Toppings** group of twelve options with `allowCustom`; `?shop=closed` answers `503 { ok: false, error: "the shop
328
+ is closed" }` so you can see the fallback. Its `run` echoes its input, so the submitted array is visible.
329
+
330
+ ## 3. The frontend — `src/ui.tsx`
331
+
332
+ A plugin's `frontend` (`dist/ui.js`) is a single ES module the host serves at `/plugins-assets/<id>` and the
333
+ shell `import()`s. It exports the components the manifest's `export` names point at. It imports **only the
334
+ import-map externals** — `react` (via `react/jsx-runtime`), `react-dom`, `react-router`, `@rackbops/ui-react` and
335
+ `@ac/host` — so **React is never bundled**: the shell and every plugin share one React copy through the host
336
+ page's import map.
337
+
338
+ The built `index.html` template keeps those paths unversioned, but the host adds its running version as a
339
+ `?v=<encoded-version>` query when it serves the shell. There are seven mapped specifiers and six distinct
340
+ vendor URLs because `react-dom` and `react-dom/client` deliberately share the byte-identical
341
+ `/vendor/react-dom.js?v=…` URL. `/index.js` receives the same query. Plugins must keep importing the bare
342
+ specifiers above—never `/vendor/*.js` or a relative `src/vendor/*` module—so the browser cannot load a second
343
+ React or `@ac/host` instance. Both versioned and legacy asset URLs remain valid and revalidating; the query is a
344
+ deployment cache key, not a filesystem path or a change to the plugin ABI.
345
+
346
+ ```tsx
347
+ import { Card } from "@rackbops/ui-react"
348
+
349
+ export function HelloPanel() {
350
+ return (
351
+ <Card>
352
+ <h1>Hello from a plugin</h1>
353
+ <p>This panel is contributed by plugins/hello — the reference plugin.</p>
354
+ </Card>
355
+ )
356
+ }
357
+
358
+ export function HelloCard() {
359
+ return <Card>Hello, from a contributed card.</Card>
360
+ }
361
+ ```
362
+
363
+ A card's manifest `sizes` (`["2x1"]` above, `<width>x<height>`) names the grid slots it renders at; the
364
+ Overview dashboard (#77) reads only the **height** half as the card's own default row-span tier (1/3, 2/3, or
365
+ 3/3) — `sizes[0]`'s second number, falling back to 2 when that's missing, unparseable, or outside 1-3. A viewer's ⚙ menu can
366
+ override a card's height per layout; picking that same default height back clears the override rather than
367
+ pinning it, so an un-touched card keeps tracking whatever this manifest declares even after a redeploy changes
368
+ it.
369
+
370
+ ### The `@ac/host` browser module
371
+
372
+ `@ac/host` (typed as `BrowserHostApi` in `@ac/contract`) is the one shared module instance the shell and every
373
+ plugin import through the host page's import map — upgrading it upgrades every plugin, with no plugin rebuild.
374
+ Methods are added additively. **Inside the console**, every plugin-facing surface below is safe to call from a
375
+ mounted panel/card's effect **or** at bundle import time: a call made before the shell has mounted its chrome is
376
+ held and delivered, in order, once it does — a plugin never has to wait for the shell itself. **Outside the
377
+ console** (a bundle that never runs inside the shell at all) that hold is permanent for `header.registerButton`,
378
+ `actions.open` and `quickcast.register` — there is no shell to eventually deliver to, so the call simply has no
379
+ effect — except `toast`, which renders through a minimal, self-contained fallback after a tick once nothing has
380
+ claimed it, so a toast is never silently lost.
381
+
382
+ `theme.token` resolves a `--rb-*` custom property to its current value:
383
+
384
+ ```ts
385
+ import { theme } from "@ac/host"
386
+
387
+ const accent = theme.token("--rb-accent")
388
+ ```
389
+
390
+ `toast` shows a message through the shell's toast portal. Called before the shell mounts, it is buffered (capped
391
+ so an import-time loop can't grow it without bound) and flushed once it does; only if no shell ever mounts at
392
+ all does it fall back to a minimal, self-contained DOM toast — so a plugin calling `toast` never silently loses
393
+ a message:
394
+
395
+ ```ts
396
+ import { toast } from "@ac/host"
397
+
398
+ toast("hi from hello")
399
+ ```
400
+
401
+ `header.registerButton` puts a button in the shell header and returns the unregister — the unregister works
402
+ whether or not the button has been delivered yet (cancels a pending registration, or removes a delivered one):
403
+
404
+ ```ts
405
+ import { header } from "@ac/host"
406
+
407
+ const unregister = header.registerButton({ id: "hello-scan", label: "Scan", onClick: () => {} })
408
+ ```
409
+
410
+ `actions.open` opens the universal confirm modal for a registered action — the plugin's own, or another
411
+ plugin's (`pluginId` names it; the Board and the Artifacts action bar open `provider-github`'s and others').
412
+ `target` scopes the draft to a specific object, e.g. an issue number, when the action isn't `scope: "global"`.
413
+ It is fire-and-forget, held until the shell mounts, and dropped if it never does:
414
+
415
+ ```ts
416
+ import { actions } from "@ac/host"
417
+
418
+ actions.open({ pluginId: "hello", actionId: "hello.ping" })
419
+ ```
420
+
421
+ `input` seeds the form (`actions.open({ pluginId: "provider-github", actionId: "issue.comment", target:
422
+ "acme/kb#2", input: { repo: "acme/kb", number: 2 } })`). A seed wins over the stored draft for the same
423
+ `target` **for the fields it names** (#478): every other field keeps what the draft holds, so a comment typed
424
+ into a modal opened from a row survives a cancel and a reopen of the same row, while a seeded `repo` or `number`
425
+ is never stale. A field the seed names as empty (`""`, `[]`, `0`, `false`) still wins; only a key that is absent
426
+ (or `undefined`) leaves the field to the draft, and a field in neither gets its default. So seed only what you
427
+ know: a key seeded as `""` because the value is unknown wipes what the operator typed for that field on a reopen
428
+ -- omit the key instead. A key that is not a
429
+ field of the action is dropped, from the seed and from the draft. The merged values are written back as the
430
+ draft at once, so a reload keeps exactly what the form shows; a draft is still kept on Cancel and on a failed
431
+ submit and cleared on a successful one, under the same `ac-draft:<pluginId>/<actionId>/<scopeKey>` key.
432
+
433
+ **A seed must name every field whose meaning depends on another field it names.** `issue.labels` diffs
434
+ `originalLabels` (what the form showed) against `labels` (what is ticked), so the Board seeds both; a caller that
435
+ seeded only `originalLabels` would get a fresh snapshot diffed against a stale drafted `labels`, and remove
436
+ labels nobody unticked.
437
+
438
+ `actions.open` returns nothing, and cannot tell you whether the action ran. `actions.subscribe` is that signal:
439
+ the listener is called once for every action the modal dispatched and got a result for — success or failure,
440
+ and whoever opened it (your own `actions.open`, or the Ctrl/Cmd+K palette) — with the action's `pluginId`,
441
+ `actionId`, `ok`, the `error` message on a failure, and the opened ref's `target` (absent when the ref had none,
442
+ as from the palette) — what the action was opened *for*, not what the operator finally typed into the form. It
443
+ carries no input. Cancelling, or closing before the confirm, calls nothing. Use it to
444
+ refresh what an action changed — the Board refetches the card an `issue.*` action named, so a closed issue's row
445
+ goes at once instead of at the next poll. It returns the unsubscribe; a listener that throws is logged and
446
+ skipped, and never reaches the modal or another listener. It needs no shell to be mounted: it simply hears
447
+ nothing until one dispatches something.
448
+
449
+ ```ts
450
+ import { actions } from "@ac/host"
451
+
452
+ const off = actions.subscribe((e) => {
453
+ if (e.ok && e.pluginId === "provider-github" && e.actionId.startsWith("issue.")) {
454
+ // refetch what the action changed; e.target says what it was opened for (absent from the palette)
455
+ }
456
+ })
457
+ // ... and in the effect's cleanup: off()
458
+ ```
459
+
460
+ Every tabbed panel, and every action of a plugin that loaded without error, is automatically listed in the
461
+ Ctrl/Cmd+K quickcast palette — nothing to register for that. (A panel that lost a contested nav slot —
462
+ `buildTabs` skips a non-owning `navOwner: false` panel — has no tab but IS still listed: `quickcastPanels`
463
+ offers every loadable panel on purpose (#65), so the palette stays a keyboard way back to it. Actions are pure
464
+ data, so they surface even for a plugin with no frontend at all.) `quickcast.register` is for anything beyond a plain
465
+ panel/action jump: register a function returning the plugin's current items, and the palette calls it on open
466
+ and again on every filter keystroke (matching each item's `label` and `hint` — see below). Items appear after
467
+ the built-in panel/action items, in registration order; the plugin's `run` decides what happens (navigate, open
468
+ its own UI, call `actions.open`) and the palette closes before calling it. Held until the shell mounts, the
469
+ same as `header.registerButton`. This is the snippet `HelloPanel` itself registers in an effect:
470
+
471
+ ```tsx
472
+ import { quickcast, toast } from "@ac/host"
473
+ import { useEffect } from "react"
474
+
475
+ useEffect(
476
+ () => quickcast.register(() => [{ label: "Hello: say hi", run: () => toast("hi from hello") }]),
477
+ [],
478
+ )
479
+ ```
480
+
481
+ A `hint` (optional, e.g. a category or the plugin id) is searched the same as `label`, ranked after it — so
482
+ `>hel` finds an item whose label doesn't contain "hel" but whose hint does — and renders as muted secondary
483
+ text in the row.
484
+
485
+ `fetch` makes a same-origin, credentialed, CSRF-aware request to a host/plugin route — a relative `path`
486
+ resolves against the console origin; a cross-origin absolute URL throws rather than being followed. A panel
487
+ reads a provider's routes through this rather than a bare `window.fetch`; a card should reach for `cached`
488
+ below instead:
489
+
490
+ ```ts
491
+ import { fetch } from "@ac/host"
492
+
493
+ const res = await fetch("/api/x/provider-github/issues?repo=o/n")
494
+ const data = await res.json()
495
+ ```
496
+
497
+ `cached` and `invalidate` wrap `fetch` in a per-path 45 s (default) TTL cache (E7 #77): a call within `ttlMs`
498
+ of the path's last fetch reuses that same underlying request instead of issuing a new one — each caller
499
+ still gets its own `Response` (`.clone()`), so two independent reads of a cache hit never collide on an
500
+ already-consumed body. Only `GET` may be cached (`cached` throws on any other method); a rejected fetch is
501
+ never cached — the very next call retries. **A card reads through `cached`, never bare `fetch`, so Refresh
502
+ and the Overview heartbeat (both call `invalidate`) actually reach it** — a card that bypasses `cached` keeps
503
+ serving its own stale response until its own next poll, deaf to both:
504
+
505
+ ```ts
506
+ import { cached, invalidate } from "@ac/host"
507
+
508
+ const res = await cached("/api/x/scans/context-pressure") // a hit inside 45 s reuses the one in-flight/last fetch
509
+ const data = await res.json()
510
+
511
+ // ... later, e.g. from a Refresh button:
512
+ invalidate("/api/x/scans/context-pressure") // this one path
513
+ invalidate() // every cached path
514
+ ```
515
+
516
+ **Type resolution and testing.** A plugin importing `@ac/host` needs three things beyond the import itself —
517
+ the guide is written against hello, so this is exactly what it does:
518
+
519
+ 1. **The ambient declaration.** TypeScript's `declare module "@ac/host"` (in
520
+ `packages/ui-shell/src/ac-host.d.ts`, what lets the shell's own program see it) doesn't cross a package
521
+ boundary just because your plugin also depends on `@ac/contract` — your plugin's own `tsconfig.ui.json`
522
+ only sees files it includes. Copy [`plugins/hello/src/ac-host.d.ts`](../../plugins/hello/src/ac-host.d.ts)
523
+ into your plugin (it deliberately re-declares only the plugin-facing surface — no `ShellChrome`/`__shell`,
524
+ which are shell-internal) and add it to that tsconfig's `include`, the way hello's
525
+ [`tsconfig.ui.json`](../../plugins/hello/tsconfig.ui.json) does. A vendored copy can silently drift from
526
+ the shell's own, the same risk as the `@ac/contract` schema copy in §5 — guard it the way
527
+ [`plugins/hello/src/ac-host-drift.test.ts`](../../plugins/hello/src/ac-host-drift.test.ts) does, pinning
528
+ the copy's exported surface against the shell's line-for-line.
529
+ 2. **The Vitest alias.** `@ac/host` only resolves for real through the host page's import map, which a plain
530
+ Node test runner has no equivalent of. If you test the frontend through Vitest, alias the specifier to
531
+ `packages/ui-shell/src/vendor/ac-host-entry.mjs` (the shell's own implementation — reuse it rather than
532
+ mocking), the way [`plugins/hello/vitest.config.ts`](../../plugins/hello/vitest.config.ts) does.
533
+ 3. **Leaving it external in the build.** Nothing extra is needed here as long as you build with plain `tsc`
534
+ (§3, no bundler) — `tsc` only transpiles syntax, so `import { toast } from "@ac/host"` is emitted into
535
+ `dist/ui.js` verbatim, unresolved, for the browser's import map to satisfy at runtime, exactly like the
536
+ `react`/`@rackbops/ui-react` imports. (This only becomes something to configure explicitly if a plugin
537
+ ever builds its frontend with a bundler instead — then `@ac/host` needs the same external treatment
538
+ `packages/ui-shell/vendor.config.mjs` gives `react`/`react-dom`.)
539
+
540
+ Build it with `tsc` and `"jsx": "react-jsx"` — `tsc` under `nodenext` preserves the bare `@rackbops/ui-react`
541
+ import and injects `import { jsx } from "react/jsx-runtime"`, emitting exactly the servable ESM the import map
542
+ resolves. Do **not** bundle (no rollup/esbuild bundling of React) — that would ship a second React copy.
543
+
544
+ hello uses two tsconfigs so the server (no JSX) and the frontend (JSX) build separately:
545
+
546
+ `tsconfig.json` (server):
547
+
548
+ ```json
549
+ {
550
+ "extends": "../../tsconfig.base.json",
551
+ "compilerOptions": { "rootDir": "src", "outDir": "dist" },
552
+ "include": ["src/server.ts", "src/server.test.ts"]
553
+ }
554
+ ```
555
+
556
+ `tsconfig.ui.json` (frontend):
557
+
558
+ ```json
559
+ {
560
+ "extends": "../../tsconfig.base.json",
561
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "jsx": "react-jsx" },
562
+ "include": ["src/ui.tsx", "src/ui.test.tsx"]
563
+ }
564
+ ```
565
+
566
+ and `package.json` builds + typechecks both:
567
+
568
+ ```json
569
+ "scripts": {
570
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.ui.json --noEmit",
571
+ "build": "tsc && tsc -p tsconfig.ui.json",
572
+ "test": "vitest run"
573
+ }
574
+ ```
575
+
576
+ `react` and `@rackbops/ui-react` are `dependencies` (needed to typecheck the frontend); at runtime they resolve
577
+ through the import map, not from the plugin's `node_modules`. `@rackbops/ui-react` is **optional** — a frontend
578
+ can use plain JSX and drop it, keeping only `react`. And if you split the server or frontend across multiple
579
+ source files, each relative import carries an explicit `.js` extension (`import { x } from "./util.js"`, even
580
+ from a `.ts` source) — the workspace is `moduleResolution: nodenext`.
581
+
582
+ If your **server** takes on a runtime npm dependency (not just a type-only `import type`), it must be bundled
583
+ into `dist/server.js` — the image ships a plugin's `package.json` + `dist/*.js` only, never a plugin
584
+ `node_modules`, and `scripts/assert-image.mjs` asserts every `plugins/*` id actually loads in the booted image
585
+ (so a plugin shipping an unresolvable bare import goes red there, not silently in production). `plugins/issues-classic`
586
+ is the worked example (#312): `tsconfig.build.json` emits the server alone to `dist-tsc/`, and
587
+ `rollup.config.mjs` bundles that into `dist/server.js` with everything except Node's own `node:*` built-ins
588
+ inlined — `yaml` (its `views.yaml` parser) included. `createPlugin` stays pure regardless of how the server is
589
+ built. The **frontend** is never bundled this way (§3 above) — `@ac/host`/`react`/`@rackbops/ui-react` resolve
590
+ through the shell's import map, not a bundle.
591
+
592
+ ## 4. `CHANGELOG.md` — required, and version-matched
593
+
594
+ Every plugin needs a `CHANGELOG.md` with a section matching its `package.json` `version`, in Keep-a-Changelog
595
+ form `## <version> - <YYYY-MM-DD>`:
596
+
597
+ ```markdown
598
+ # Changelog
599
+
600
+ ## 0.1.0 - 2026-09-21
601
+
602
+ - A second action, `hello.order`, and a second route, `GET /toppings` (#463): the worked example of the
603
+ action-form option pickers. The action has a `hidden` field (`ticket`), a `string` field (`shop`) and a
604
+ `multiselect` (`toppings`) whose choices come from `GET /toppings?shop=<shop>` (`optionsFrom`): two
605
+ groups -- an exclusive "Size" and a plain, coloured "Toppings" -- and a free-form add. `?shop=closed`
606
+ answers 503 with `{ ok: false, error: "the shop is closed" }`, so the picker's text-box fallback is
607
+ reachable in a browser. The handler echoes the input it received, so the submitted `toppings` array is
608
+ visible. `hello.ping` is unchanged.
609
+ ```
610
+
611
+ This is a **hard requirement**: `scripts/generate-index.mjs` scans every plugin's `acPlugin` block + CHANGELOG
612
+ into the committed `plugins.json` (the shared plugin-index envelope), and **throws** — failing `pnpm run check`
613
+ — if the current version has no matching CHANGELOG section. Bump the version and add its section together.
614
+
615
+ ## 5. Vendor the contract — `vendor/@ac/contract/schema.json` (when you need it)
616
+
617
+ An in-monorepo plugin depends on `@ac/contract: workspace:*`, so it builds against the **live** contract and a
618
+ host-side change reaches it immediately — nothing to drift from. A plugin that instead pins or publishes against
619
+ a fixed `@ac/contract` builds against a **frozen copy**, which can silently fall behind the host. The guard
620
+ against that is a vendored snapshot of the schema:
621
+
622
+ ```sh
623
+ cp packages/contract/schema.json plugins/<id>/vendor/@ac/contract/schema.json
624
+ ```
625
+
626
+ `check-contract` (`packages/host/src/check-contract.test.ts`) asserts that **every vendored copy that exists** is
627
+ identical to the live `packages/contract/schema.json`, and that at least one plugin vendors one so the guard is
628
+ never a no-op (the reference plugin `hello` provides that copy). So vendoring is **optional for a `workspace:*`
629
+ monorepo plugin** and the pattern to follow for a pinned or published one; when the contract changes you
630
+ **re-vendor** (copy again) and commit — that acknowledgement is the point of the check.
631
+
632
+ ## 6. Build, package, and verify it loads
633
+
634
+ ```sh
635
+ pnpm install # once, after adding the plugin's deps
636
+ pnpm run check # build + regenerate plugins.json + lint + typecheck + test
637
+ ```
638
+
639
+ `pnpm run check` runs `vitest run` in every package, and Vitest **fails a package that ships no test file** (with
640
+ *no test files found*) — so write at least one test; copy the shape of `hello`'s `src/server.test.ts` (a
641
+ `createPlugin` smoke test with a mocked `host`) and `src/ui.test.tsx` (a component-export test), both listed in
642
+ the tsconfig `include`s above. `check` also regenerates the committed `plugins.json` and then
643
+ **fails if it differs from `HEAD`** — adding a plugin changes it, so `pnpm run check` stays red until you
644
+ **commit** the regenerated `plugins.json` (staging is not enough; the check diffs against the last commit).
645
+
646
+ Then confirm the plugin **loads and activates**. Build, then boot the host pointed at your `plugins/` dir — both
647
+ `AC_PLUGINS_DIR` and `AC_STATE_DIR` must be **absolute** paths (the host rejects a relative one at startup), and
648
+ `AC_STATE_DIR` must be **writable**: the host opens its SQLite DB at `<AC_STATE_DIR>/ac.db`, and the image
649
+ default `/state` does not exist on a dev box.
650
+
651
+ ```sh
652
+ pnpm -r build
653
+ mkdir -p .dev-state
654
+ AC_PLUGINS_DIR="$PWD/plugins" AC_STATE_DIR="$PWD/.dev-state" node packages/host/dist/main.js
655
+ ```
656
+
657
+ The host listens on `http://127.0.0.1:8787` by default (override with `AC_HOST` / `AC_PORT`). Read `GET
658
+ /api/plugins` there (the envelope is `{ "plugins": [ … ] }`): your plugin appears with **no `error`** and its
659
+ `frontend`/`panels`/`cards`/`actions` — a contribution you didn't declare is simply absent. `GET /api/x/<id>/…`
660
+ hits your routes; `/plugins-assets/<id>` serves your frontend. Its entry also carries `origin` — the shell marks
661
+ a tab dashed when the plugin was found under an *installed* plugins dir rather than the image's own `plugins/`;
662
+ this is derived from where the host found it, so there is nothing a plugin can declare to change it.
663
+
664
+ **Packaging into the image.** `main.js` discovers plugins at `../plugins` (i.e. `/app/plugins` in the image), so
665
+ the `Dockerfile` copies each plugin's runtime files there: `package.json` plus the whole `dist/`
666
+ directory (`COPY --from=build /repo/plugins/<name>/dist ./plugins/<name>/dist`) — **not** a
667
+ hand-listed set of individual files (#401). Your server may freely split across sibling emitted
668
+ modules (a plain `tsc` build, e.g. so one can be unit-tested directly — `plugins/provider-artifacts`,
669
+ #67, is the reference) with no extra Dockerfile work: the whole-`dist/` copy ships whatever you
670
+ emit, automatically. This is safe from shipping a compiled `*.test.js` alongside your real modules
671
+ only because the container's `pnpm -r build` runs your **build-only** tsconfig
672
+ (`tsconfig.build.json`/`tsconfig.ui.build.json`, excluding every `*.test.ts`), never the
673
+ test-covering one `typecheck` uses — guarded by `scripts/check-no-test-emit.mjs` (#350, run twice in
674
+ `pnpm run check`), which fails the moment any package's build leaves a compiled test file under
675
+ `dist*/`. A brand-new **plugin directory** still needs its own two `COPY` lines (`scripts/
676
+ check-dockerfile-plugin-copy.mjs`, wired into `pnpm run check`, fails naming any `acPlugin` plugin
677
+ copied file-by-file or not copied at all; `scripts/assert-image.mjs` is the runtime-load proof that
678
+ catches a plugin dir never `COPY`ed at all, in the real booted image).
679
+
680
+ ## 7. Host-agent capabilities
681
+
682
+ A plugin that needs to reach a paired machine's host agent (a git status, a `claude`/`gh` run, ...)
683
+ declares which capabilities it may use in its manifest, as a sibling of `contributes` rather than
684
+ inside it -- a capability isn't a contribution point, it's a permission:
685
+
686
+ ```json
687
+ "acPlugin": {
688
+ "id": "hello",
689
+ ...
690
+ "capabilities": ["agent.git.read"]
691
+ }
692
+ ```
693
+
694
+ Each declared capability appears as its own row in the Agents panel's Grants grid, one column per
695
+ paired machine; an operator grants or revokes it there, per machine, and nothing here runs until it
696
+ is granted. `hello`'s own manifest declares `agent.git.read` as the reference declaration (`hello`
697
+ never actually calls it -- the declaration alone is what the grid, a live pairing pass, and this
698
+ guide need).
699
+
700
+ At runtime, call a granted capability on a specific paired machine through `host.agent`:
701
+
702
+ ```ts
703
+ const machines = await host.agent.list() // paired machines, each with its current grants
704
+ const outcome = await host.agent.call(machines[0].id, "GET", "/v1/repos")
705
+ if (outcome.ok) {
706
+ // outcome.status, outcome.body
707
+ } else {
708
+ // outcome.error.kind: "unknown-machine" | "refused" | "grant" | "pin" | "network" | "http"
709
+ }
710
+ ```
711
+
712
+ `outcome.error.kind === "grant"` is the one worth showing to the person almost verbatim -- it
713
+ carries the agent's own refusal text (`"<capability> is not granted to <plugin> on <machine>"`). The
714
+ others are the console's problem to describe differently: `unknown-machine` (a stale machine id),
715
+ `refused` (the path isn't a `/v1/*` verb, or is `/v1/pair`/`/v1/grants` -- the control plane, never
716
+ reachable this way, whatever the path spelling), `pin` (the agent's certificate changed since
717
+ pairing), `network`, `http` (an unexpected status the console doesn't special-case).
718
+
719
+ A verb that can genuinely run long (a scan, a build) needs a fifth, optional `call` argument:
720
+ `host.agent.call(machineId, method, path, body, { timeoutMs })` sets how long the console waits for
721
+ the agent's response *headers* (undici's `headersTimeout`; the default is 300s). Pass the verb's own
722
+ limit plus a margin -- `scans` passes `agent limit + 30s` -- so the agent's own `504` reaches the
723
+ console before the console gives up and reports a bare `network` timeout instead.
724
+
725
+ ### Streaming a job into the tasks drawer
726
+
727
+ A capability whose verb starts a long-running agent job (`exec/claude`'s `{ jobId }`, #54) doesn't
728
+ need to poll or parse SSE itself -- `host.agent.streamJob(machineId, jobId, { label })` opens the
729
+ agent's `GET /v1/jobs/:jobId/events` through the same pinned client, registers a new host task for
730
+ `label`, and relays every `line`/`done` frame into it as the job runs; it resolves `{ taskId }` once
731
+ the agent has confirmed the stream is open (a `200`), or rejects (starting no task) on anything
732
+ else -- an invalid `jobId`, an unknown machine, or a non-`200` from the agent (a `404`, a `403`
733
+ refusal, ...), each surfaced the same way `call` surfaces them. The relay itself runs detached from
734
+ that promise, so nothing in a plugin's own code touches the stream at all:
735
+
736
+ ```ts
737
+ const { jobId } = await execResult // however the plugin's own verb call started the job
738
+ const { taskId } = await host.agent.streamJob(machineId, jobId, { label: `code: ${prompt}` })
739
+ // taskId names the task now streaming in the tasks drawer -- the action can simply return it.
740
+ ```
741
+
742
+ `plugins/claude-runner` (#54) is the reference: `task.code` and `routine.rerun` call `exec/claude`
743
+ then `streamJob` and return the task id, never touching SSE framing themselves.
744
+
745
+ ## 8. The needs-attention rail (#78)
746
+
747
+ `contributes.attention: true` marks a plugin that serves `GET /api/x/<id>/attention` ->
748
+ `{ ok: true, items: AttentionItem[] }` (`@ac/contract`'s `attention.ts`). The `overview` plugin's
749
+ rollup card discovers every contributor from `GET /api/plugins`'s `PluginView.attention` field (no
750
+ second manifest read) and polls each `/attention` route, merging, sorting by severity, and deduping
751
+ by `${source}:${id}`.
752
+
753
+ ```ts
754
+ interface AttentionItem {
755
+ id: string // unique within YOUR plugin; the rollup's dedupe key is `${source}:${id}`
756
+ severity: "danger" | "warn" | "info"
757
+ source: string // your own plugin id -- the rollup shows it as the row's source
758
+ text: string // always a counted sentence: "3 open actions in the ledger", never "some actions"
759
+ badge?: string
760
+ href?: string // an external link; wins over `target` when both are set
761
+ target?: string // an in-app destination: "widget:<cardId>" or "panel:<pluginId>"
762
+ hint?: string // the exact command to run, or the reason -- shown as a tooltip/expander
763
+ action?: { kind: "issue"; repo?: string; title: string; body: string } // opens provider-github's issue.create, seeded
764
+ }
765
+ ```
766
+
767
+ **Own the fact, own the row.** The rollup itself derives nothing -- it only merges what contributors
768
+ already computed. If your plugin is the one that KNOWS a fact is wrong (a failed job, a stale scan, a
769
+ stuck push), your plugin is the one that turns it into a row, at the severity and with the hint that
770
+ makes sense given what only you know about it. Don't reach into another plugin's storage or routes to
771
+ compute a row about ITS facts -- ask that plugin's own maintainer to add the row instead.
772
+
773
+ `scans` (`scan.stale.<kind>`, warn, one row per kind with a stale machine) and `provider-artifacts`
774
+ (`store.push-rejected` danger, `ledger.open`/`reviews.open`/`reviews.untracked` warn,
775
+ `publish.error.<machineId>` warn) are the reference contributors; `overview`'s own server (`GET
776
+ /attention`'s handler) is the reference for a plugin computing rows about facts it has PRIVILEGED,
777
+ cross-plugin, host-internal access to (job health across every other plugin, paired-agent
778
+ reachability) rather than its own storage -- `host.jobs.machineRunsOf(pluginId, jobId)` and
779
+ `host.plugins.list()` are additive `HostApi` capabilities added for exactly this, deliberately
780
+ scoped no wider than the data `GET /api/jobs/:pluginId/:jobId/runs` and `GET /api/plugins` already
781
+ serve publicly over HTTP.
782
+
783
+ ## 9. Building a plugin outside this repo (#450)
784
+
785
+ Everything above is written from inside this monorepo, where a plugin depends on `@ac/contract:
786
+ workspace:*` and builds against the **live** contract with nothing to drift from. A plugin that
787
+ instead lives in its **own repository** — the normal shape for a third-party plugin — depends on the
788
+ **published** [`@rackbops/ac-plugin-contract`](https://www.npmjs.com/package/@rackbops/ac-plugin-contract)
789
+ package instead: the same `HostApi`, manifest types, and generated JSON Schema this guide has been
790
+ using all along, assembled by `packages/plugin-contract/build.mjs` from this repo's own `@ac/contract`
791
+ build, the shell's `ac-host.d.ts`, and this very file (shipped inside the package as `AUTHORING.md`).
792
+ Its version always equals the `artifact-console` host's own version — pin your `devDependency` the
793
+ way you'd pin against a specific host release.
794
+
795
+ **The devDependency:**
796
+
797
+ ```sh
798
+ pnpm add -D @rackbops/ac-plugin-contract @types/node @types/react
799
+ ```
800
+
801
+ `@types/react` is for a frontend written as `.tsx`: the tsconfig below compiles JSX with
802
+ `"jsx": "react-jsx"`, and without React's types every JSX tag fails (`TS2875`, `TS7026`). A frontend
803
+ with no JSX can leave it out. React itself is never a dependency of your package: the console's import
804
+ map provides it at runtime (the externals below).
805
+
806
+ `HostApi`'s types (and `@octokit/rest`'s, which it references) use fetch-API globals such as
807
+ `Request`, `Response`, `AbortSignal`, `Blob` and `File`. With TypeScript's default `lib` the DOM
808
+ library declares them; a server-side tsconfig that narrows `lib` to exclude DOM (this repo's own uses
809
+ `["ES2023"]`) needs `@types/node` for them instead. A plugin server runs on Node, so the tsconfig
810
+ below names `"node"` — which is why `@types/node` must be installed: naming it without the package
811
+ fails with `TS2688: Cannot find type definition file for 'node'`.
812
+
813
+ **A tsconfig** — the same `nodenext` shape every in-repo plugin already builds with (§3 above), plus
814
+ the ambient `@ac/host` declaration this package publishes as a types-only subpath. Naming anything in
815
+ `types` REPLACES TypeScript's default auto-inclusion of every installed `@types/*` package (the same
816
+ reason this repo's own `tsconfig.base.json` explicitly lists `"node"`), so `"node"` is named alongside
817
+ the host declaration or Node's own globals (`process`, `Buffer`, …) disappear from your server code:
818
+
819
+ ```json
820
+ {
821
+ "compilerOptions": {
822
+ "module": "nodenext",
823
+ "moduleResolution": "nodenext",
824
+ "strict": true,
825
+ "jsx": "react-jsx",
826
+ "types": ["node", "@rackbops/ac-plugin-contract/host"]
827
+ }
828
+ }
829
+ ```
830
+
831
+ `@rackbops/ac-plugin-contract/host` is what replaces copying `plugins/hello/src/ac-host.d.ts` by hand
832
+ (§3, step 1, the in-repo path): the published package's own `./host` export IS that ambient
833
+ declaration, pre-stripped of the shell-internal `ShellChrome`/`__shell` surface a plugin was never
834
+ meant to see, kept in sync with the live shell automatically on every release rather than by a
835
+ vendored copy a plugin author has to remember to update.
836
+
837
+ **Your server code:**
838
+
839
+ ```ts
840
+ import type { HostApi, Plugin } from "@rackbops/ac-plugin-contract"
841
+
842
+ export function createPlugin(host: HostApi): Plugin {
843
+ return { activate: async () => { host.log.info("hi") } }
844
+ }
845
+ ```
846
+
847
+ **Your frontend code** imports `@ac/host` exactly as an in-repo plugin does (§3) — the specifier is
848
+ never a real dependency of your package (it resolves through the console's own import map at
849
+ runtime, never through your `node_modules`), so don't add it to `package.json` at all; the
850
+ `types` array above is what gives it a type without one.
851
+
852
+ **The rollup externals**, if your frontend builds with a bundler instead of plain `tsc` (§3's own
853
+ "leaving it external" note): the same seven import-map specifiers every in-repo plugin's frontend
854
+ resolves against, so your bundle inlines none of them — `react`, `react/jsx-runtime`, `react-dom`,
855
+ `react-dom/client`, `react-router`, `@ac/host`, `@rackbops/ui-react`.
856
+
857
+ **Vendoring `schema.json` with a drift check.** §5 above vendors a schema snapshot for a plugin
858
+ pinned against a *frozen* contract, which an out-of-repo plugin always is (there is no live contract
859
+ to build against outside this repo at all). Vendor the published copy, and check it on every install
860
+ rather than trusting the pin alone — the console's own manifest schema can change between the
861
+ version you built against and the version you're actually running against:
862
+
863
+ ```sh
864
+ mkdir -p vendor && cp node_modules/@rackbops/ac-plugin-contract/schema.json vendor/schema.json
865
+ ```
866
+
867
+ ```js
868
+ // scripts/check-schema-drift.mjs
869
+ import { readFileSync } from "node:fs"
870
+
871
+ const vendored = readFileSync("vendor/schema.json", "utf8")
872
+ const live = readFileSync("node_modules/@rackbops/ac-plugin-contract/schema.json", "utf8")
873
+ if (vendored !== live) {
874
+ console.error("vendor/schema.json has drifted from @rackbops/ac-plugin-contract -- re-vendor it:")
875
+ console.error(" cp node_modules/@rackbops/ac-plugin-contract/schema.json vendor/schema.json")
876
+ process.exit(1)
877
+ }
878
+ ```
879
+
880
+ **Naming your published package.** Follow `@rackbops/ac-plugin-<id>` (#105) — the same `@rackbops/`
881
+ scope this whole family of packages uses (`ac-agent`, `ac-plugin-contract` itself), so
882
+ `<id>` matches your manifest's own `acPlugin.id` and an operator installing it can tell at a glance
883
+ what it's for.
884
+
885
+ ## The seven contribution points, in one place
886
+
887
+ | Point | Declared in `contributes` | Supplied by |
888
+ |---|---|---|
889
+ | Panel | `panels[]` (`id`, `title`, `route`, `export`, `nav?`, `order?`) | a component export in `ui.js` |
890
+ | Card | `cards[]` (`id`, `title`, `sizes`, `export`) | a component export in `ui.js` |
891
+ | Action | `actions[]` (`id`, `scope`, `title?`, `confirm?`, `danger?`, `fields?`) | `host.actions.register({ id, run })` |
892
+ | Job | `jobs[]` (`id`, `schedule`, `timeoutSec`, `runOn`) | `runJob(ctx)` dispatching on `ctx.jobId` |
893
+ | Route | `routes[]` (`method`, `path`) | `host.routes.register(method, path, handler)` |
894
+ | Setting | `settings[]` (`key`, `type`, `default?`) | read via `host.settings.get/set` |
895
+ | Attention | `attention: true` | `host.routes.register("GET", "/attention", handler)` -> `{ ok: true, items }` |
896
+
897
+ **Panel order (#472).** The tab strip, the narrow-screen nav drawer and the quickcast panel list all show
898
+ panels in one order, which the host resolves per request: `GET /api/plugins` stamps every panel with an
899
+ integer `rank` and the shell only sorts by it. The rule, in priority order:
900
+
901
+ 1. panels named in the operator's **`tab_order`** host setting -- a comma-separated list of panel ids, edited
902
+ in Admin > Settings (or set with `AC_TAB_ORDER`), applied on the next page load -- in the order listed; an
903
+ id that names no loaded panel is ignored, never an error. (A panel `id` has no fixed grammar, but the
904
+ setting cannot name one that contains a space or a comma, so pick panel ids without them);
905
+ 2. then panels whose manifest declares **`order`** -- an optional finite number on the panel, lower first --
906
+ with a `bundled` plugin's panel always ahead of an `installed` plugin's, so a third-party package
907
+ declaring `order: -1` cannot jump ahead of the bundled ordered panels (it does still precede every panel
908
+ that declares no `order`);
909
+ 3. then every other panel, by plugin directory name and then declaration order (where every panel sat
910
+ before `order` existed).
911
+
912
+ Ties break the same way, so the order is total. `order` is a hint the operator can always override with
913
+ `tab_order`, so leave it off unless your panel belongs at the front (the Board declares `10`, Issues `20`,
914
+ which puts them right after Home). `Home` is always first and is not orderable. A panel that lost a
915
+ contested nav slot still has no tab, and still appears in quickcast at its ranked position.
916
+
917
+ A card never renders its own heading -- the wrapper supplies it from the manifest `title`
918
+ (`HomeCards.tsx`'s `PluginCard`, `<Card><h3>{title}</h3>...</Card>`); a card rendering one of its
919
+ own duplicates it on screen (#399).
920
+
921
+ Two more fields exist on `Contributes` (`packages/contract/src/manifest.ts`) but aren't live
922
+ contribution points yet: **`themes`** (`ThemeContribution: {id, title?}`) is schema-declared with no
923
+ host-side reader at all -- validates, but nothing consumes it. **`provides`/`consumes`**
924
+ (`SlotRef: {slot, version}`) ARE live and load-bearing, but for a different kind of plugin than this
925
+ guide's worked example: a slot (`issues@1`, `artifacts@1`, `auth@1`, `notify@1`) is a
926
+ named/versioned TypeScript interface one plugin **provides** and others **consume** -- the host
927
+ refuses to activate a consumer whose slot has no provider, and says why (`resolveSlots`,
928
+ `packages/host/src/plugins/slots.ts`, re-exported from `plugins/index.ts`). `ac-auth-cf-access`
929
+ (`auth@1`) and `ac-notify-discord` (`notify@1`) are the reference *providers*; see their own
930
+ `src/server.ts` for `host.slots.provide(slot, version, impl)`, and `design.md` §4 for the slot
931
+ contract itself. `hello` contributes none of the six points above via a slot (nor `attention` -- its own manifest
932
+ declares panels/cards/actions/jobs/routes/settings only, matching the intro above) -- it's the
933
+ reference for those six, not for slot provision or the attention rail; `plugins/scans` and
934
+ `plugins/overview` are the worked examples for `contributes.attention` (§8 above).