@skillcdn/console 0.4.1 → 0.4.3

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.
Files changed (299) hide show
  1. package/README.md +19 -2
  2. package/SKILLCDN.md +7 -5
  3. package/dist/api.d.ts +5 -4
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +5 -4
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +569 -48
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +34 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +283 -7
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +44 -7
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/plugin-check.d.ts +26 -0
  18. package/dist/cli/plugin-check.d.ts.map +1 -0
  19. package/dist/cli/plugin-check.js +396 -0
  20. package/dist/cli/plugin-check.js.map +1 -0
  21. package/dist/cli/plugin-cli.d.ts +20 -0
  22. package/dist/cli/plugin-cli.d.ts.map +1 -0
  23. package/dist/cli/plugin-cli.js +189 -0
  24. package/dist/cli/plugin-cli.js.map +1 -0
  25. package/dist/cli/plugin-init.d.ts +37 -0
  26. package/dist/cli/plugin-init.d.ts.map +1 -0
  27. package/dist/cli/plugin-init.js +206 -0
  28. package/dist/cli/plugin-init.js.map +1 -0
  29. package/dist/cli/plugin-main.d.ts +3 -0
  30. package/dist/cli/plugin-main.d.ts.map +1 -0
  31. package/dist/cli/plugin-main.js +28 -0
  32. package/dist/cli/plugin-main.js.map +1 -0
  33. package/dist/client.d.ts +100 -3
  34. package/dist/client.d.ts.map +1 -1
  35. package/dist/client.js +79 -7
  36. package/dist/client.js.map +1 -1
  37. package/dist/compat.d.ts +1 -1
  38. package/dist/compat.js +2 -2
  39. package/dist/components/board.d.ts +8 -0
  40. package/dist/components/board.d.ts.map +1 -1
  41. package/dist/components/board.js +59 -15
  42. package/dist/components/board.js.map +1 -1
  43. package/dist/components/comments.d.ts.map +1 -1
  44. package/dist/components/comments.js +1 -1
  45. package/dist/components/comments.js.map +1 -1
  46. package/dist/components/decision-form.d.ts +2 -1
  47. package/dist/components/decision-form.d.ts.map +1 -1
  48. package/dist/components/decision-form.js +36 -4
  49. package/dist/components/decision-form.js.map +1 -1
  50. package/dist/components/decision-list.d.ts +4 -0
  51. package/dist/components/decision-list.d.ts.map +1 -1
  52. package/dist/components/decision-list.js +29 -3
  53. package/dist/components/decision-list.js.map +1 -1
  54. package/dist/components/event-feed.d.ts +1 -1
  55. package/dist/components/event-feed.d.ts.map +1 -1
  56. package/dist/components/event-feed.js +33 -1
  57. package/dist/components/event-feed.js.map +1 -1
  58. package/dist/components/my-decisions.d.ts +2 -0
  59. package/dist/components/my-decisions.d.ts.map +1 -1
  60. package/dist/components/my-decisions.js +2 -2
  61. package/dist/components/my-decisions.js.map +1 -1
  62. package/dist/components/plugin-frame.d.ts +52 -0
  63. package/dist/components/plugin-frame.d.ts.map +1 -0
  64. package/dist/components/plugin-frame.js +314 -0
  65. package/dist/components/plugin-frame.js.map +1 -0
  66. package/dist/components/plugin-settings.d.ts +38 -0
  67. package/dist/components/plugin-settings.d.ts.map +1 -0
  68. package/dist/components/plugin-settings.js +348 -0
  69. package/dist/components/plugin-settings.js.map +1 -0
  70. package/dist/components/shell.d.ts +2 -0
  71. package/dist/components/shell.d.ts.map +1 -1
  72. package/dist/components/shell.js +26 -1
  73. package/dist/components/shell.js.map +1 -1
  74. package/dist/components/task-view.d.ts +2 -1
  75. package/dist/components/task-view.d.ts.map +1 -1
  76. package/dist/components/task-view.js.map +1 -1
  77. package/dist/components/workspace.d.ts +7 -1
  78. package/dist/components/workspace.d.ts.map +1 -1
  79. package/dist/components/workspace.js +1 -1
  80. package/dist/components/workspace.js.map +1 -1
  81. package/dist/console.css +330 -1
  82. package/dist/console.d.ts.map +1 -1
  83. package/dist/console.js +152 -41
  84. package/dist/console.js.map +1 -1
  85. package/dist/data.d.ts +9 -2
  86. package/dist/data.d.ts.map +1 -1
  87. package/dist/data.js +45 -9
  88. package/dist/data.js.map +1 -1
  89. package/dist/i18n/en.d.ts +155 -0
  90. package/dist/i18n/en.d.ts.map +1 -1
  91. package/dist/i18n/en.js +166 -0
  92. package/dist/i18n/en.js.map +1 -1
  93. package/dist/i18n/ko.d.ts.map +1 -1
  94. package/dist/i18n/ko.js +150 -0
  95. package/dist/i18n/ko.js.map +1 -1
  96. package/dist/index.d.ts +3 -1
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +3 -1
  99. package/dist/index.js.map +1 -1
  100. package/dist/limits.d.ts +2 -0
  101. package/dist/limits.d.ts.map +1 -1
  102. package/dist/limits.js +2 -0
  103. package/dist/limits.js.map +1 -1
  104. package/dist/plugin/args.d.ts +148 -0
  105. package/dist/plugin/args.d.ts.map +1 -0
  106. package/dist/plugin/args.js +369 -0
  107. package/dist/plugin/args.js.map +1 -0
  108. package/dist/plugin/bridge.d.ts +146 -0
  109. package/dist/plugin/bridge.d.ts.map +1 -0
  110. package/dist/plugin/bridge.js +125 -0
  111. package/dist/plugin/bridge.js.map +1 -0
  112. package/dist/plugin/capabilities.d.ts +20 -0
  113. package/dist/plugin/capabilities.d.ts.map +1 -0
  114. package/dist/plugin/capabilities.js +133 -0
  115. package/dist/plugin/capabilities.js.map +1 -0
  116. package/dist/plugin/context.d.ts +380 -0
  117. package/dist/plugin/context.d.ts.map +1 -0
  118. package/dist/plugin/context.js +2 -0
  119. package/dist/plugin/context.js.map +1 -0
  120. package/dist/plugin/define.d.ts +208 -0
  121. package/dist/plugin/define.d.ts.map +1 -0
  122. package/dist/plugin/define.js +482 -0
  123. package/dist/plugin/define.js.map +1 -0
  124. package/dist/plugin/definition-error.d.ts +11 -0
  125. package/dist/plugin/definition-error.d.ts.map +1 -0
  126. package/dist/plugin/definition-error.js +17 -0
  127. package/dist/plugin/definition-error.js.map +1 -0
  128. package/dist/plugin/limits.d.ts +67 -0
  129. package/dist/plugin/limits.d.ts.map +1 -0
  130. package/dist/plugin/limits.js +70 -0
  131. package/dist/plugin/limits.js.map +1 -0
  132. package/dist/plugin/load-worker.d.ts +7 -0
  133. package/dist/plugin/load-worker.d.ts.map +1 -0
  134. package/dist/plugin/load-worker.js +41 -0
  135. package/dist/plugin/load-worker.js.map +1 -0
  136. package/dist/plugin/load.d.ts +18 -0
  137. package/dist/plugin/load.d.ts.map +1 -0
  138. package/dist/plugin/load.js +62 -0
  139. package/dist/plugin/load.js.map +1 -0
  140. package/dist/plugin/manifest.d.ts +237 -0
  141. package/dist/plugin/manifest.d.ts.map +1 -0
  142. package/dist/plugin/manifest.js +411 -0
  143. package/dist/plugin/manifest.js.map +1 -0
  144. package/dist/plugin/refusals.d.ts +49 -0
  145. package/dist/plugin/refusals.d.ts.map +1 -0
  146. package/dist/plugin/refusals.js +79 -0
  147. package/dist/plugin/refusals.js.map +1 -0
  148. package/dist/plugin/rest-schemas.d.ts +556 -0
  149. package/dist/plugin/rest-schemas.d.ts.map +1 -0
  150. package/dist/plugin/rest-schemas.js +262 -0
  151. package/dist/plugin/rest-schemas.js.map +1 -0
  152. package/dist/plugin/sandbox.d.ts +23 -0
  153. package/dist/plugin/sandbox.d.ts.map +1 -0
  154. package/dist/plugin/sandbox.js +99 -0
  155. package/dist/plugin/sandbox.js.map +1 -0
  156. package/dist/plugin/testing.d.ts +45 -0
  157. package/dist/plugin/testing.d.ts.map +1 -0
  158. package/dist/plugin/testing.js +420 -0
  159. package/dist/plugin/testing.js.map +1 -0
  160. package/dist/plugin/web-react.d.ts +11 -0
  161. package/dist/plugin/web-react.d.ts.map +1 -0
  162. package/dist/plugin/web-react.js +34 -0
  163. package/dist/plugin/web-react.js.map +1 -0
  164. package/dist/plugin/web.d.ts +95 -0
  165. package/dist/plugin/web.d.ts.map +1 -0
  166. package/dist/plugin/web.js +245 -0
  167. package/dist/plugin/web.js.map +1 -0
  168. package/dist/plugin-nav.d.ts +29 -0
  169. package/dist/plugin-nav.d.ts.map +1 -0
  170. package/dist/plugin-nav.js +92 -0
  171. package/dist/plugin-nav.js.map +1 -0
  172. package/dist/plugin-testing.d.ts +2 -0
  173. package/dist/plugin-testing.d.ts.map +1 -0
  174. package/dist/plugin-testing.js +4 -0
  175. package/dist/plugin-testing.js.map +1 -0
  176. package/dist/plugin-web-react.d.ts +3 -0
  177. package/dist/plugin-web-react.d.ts.map +1 -0
  178. package/dist/plugin-web-react.js +3 -0
  179. package/dist/plugin-web-react.js.map +1 -0
  180. package/dist/plugin-web.d.ts +4 -0
  181. package/dist/plugin-web.d.ts.map +1 -0
  182. package/dist/plugin-web.js +5 -0
  183. package/dist/plugin-web.js.map +1 -0
  184. package/dist/plugin.d.ts +11 -0
  185. package/dist/plugin.d.ts.map +1 -0
  186. package/dist/plugin.js +15 -0
  187. package/dist/plugin.js.map +1 -0
  188. package/dist/router.d.ts +28 -0
  189. package/dist/router.d.ts.map +1 -1
  190. package/dist/router.js +47 -0
  191. package/dist/router.js.map +1 -1
  192. package/dist/routes.d.ts +4 -0
  193. package/dist/routes.d.ts.map +1 -1
  194. package/dist/routes.js +6 -0
  195. package/dist/routes.js.map +1 -1
  196. package/dist/schemas.d.ts +194 -9
  197. package/dist/schemas.d.ts.map +1 -1
  198. package/dist/schemas.js +98 -17
  199. package/dist/schemas.js.map +1 -1
  200. package/dist/tokens.d.ts +2 -0
  201. package/dist/tokens.d.ts.map +1 -0
  202. package/dist/tokens.js +101 -0
  203. package/dist/tokens.js.map +1 -0
  204. package/dist/vocabulary.d.ts +11 -1
  205. package/dist/vocabulary.d.ts.map +1 -1
  206. package/dist/vocabulary.js +24 -0
  207. package/dist/vocabulary.js.map +1 -1
  208. package/dist/web.d.ts.map +1 -1
  209. package/dist/web.js +2 -0
  210. package/dist/web.js.map +1 -1
  211. package/docs/specs/cli.md +12 -5
  212. package/docs/specs/plugins.md +488 -0
  213. package/docs/specs/rest.md +11 -9
  214. package/package.json +26 -8
  215. package/skills/building-a-console/SKILL.md +25 -26
  216. package/skills/building-a-console/references/configuring.md +40 -0
  217. package/skills/building-a-console/references/the-default-console.md +1 -1
  218. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  219. package/skills/building-a-plugin/SKILL.md +137 -0
  220. package/skills/deploying-a-console/SKILL.md +24 -26
  221. package/skills/deploying-a-console/references/access.md +13 -9
  222. package/skills/deploying-a-console/references/resources.md +25 -10
  223. package/skills/deploying-a-console/references/running-it-again.md +15 -0
  224. package/skills/deploying-a-console/references/sizing.md +2 -0
  225. package/skills/planning-the-board/SKILL.md +96 -0
  226. package/skills/planning-the-board/references/a-task.md +36 -0
  227. package/skills/planning-the-board/references/setting-up.md +44 -0
  228. package/skills/working-the-board/SKILL.md +43 -34
  229. package/skills/working-the-board/references/the-pack.md +19 -0
  230. package/src/api.ts +12 -0
  231. package/src/cli/cli.ts +737 -55
  232. package/src/cli/format.ts +368 -10
  233. package/src/cli/help.ts +50 -6
  234. package/src/cli/plugin-check.ts +497 -0
  235. package/src/cli/plugin-cli.ts +237 -0
  236. package/src/cli/plugin-init.ts +257 -0
  237. package/src/cli/plugin-main.ts +30 -0
  238. package/src/client.ts +322 -9
  239. package/src/compat.ts +2 -2
  240. package/src/components/board.tsx +62 -14
  241. package/src/components/comments.tsx +1 -0
  242. package/src/components/decision-form.tsx +92 -11
  243. package/src/components/decision-list.tsx +71 -2
  244. package/src/components/event-feed.tsx +42 -1
  245. package/src/components/my-decisions.tsx +4 -1
  246. package/src/components/plugin-frame.tsx +437 -0
  247. package/src/components/plugin-settings.tsx +1111 -0
  248. package/src/components/shell.tsx +82 -14
  249. package/src/components/task-view.tsx +2 -1
  250. package/src/components/workspace.tsx +18 -0
  251. package/src/console.tsx +304 -31
  252. package/src/data.ts +59 -11
  253. package/src/i18n/en.ts +175 -0
  254. package/src/i18n/ko.ts +155 -0
  255. package/src/index.ts +12 -0
  256. package/src/limits.ts +2 -0
  257. package/src/plugin/args.ts +526 -0
  258. package/src/plugin/bridge.ts +314 -0
  259. package/src/plugin/capabilities.ts +150 -0
  260. package/src/plugin/context.ts +421 -0
  261. package/src/plugin/define.ts +792 -0
  262. package/src/plugin/definition-error.ts +19 -0
  263. package/src/plugin/limits.ts +70 -0
  264. package/src/plugin/load-worker.ts +50 -0
  265. package/src/plugin/load.ts +82 -0
  266. package/src/plugin/manifest.ts +561 -0
  267. package/src/plugin/refusals.ts +94 -0
  268. package/src/plugin/rest-schemas.ts +354 -0
  269. package/src/plugin/sandbox.ts +123 -0
  270. package/src/plugin/testing.ts +612 -0
  271. package/src/plugin/web-react.ts +45 -0
  272. package/src/plugin/web.ts +403 -0
  273. package/src/plugin-nav.ts +132 -0
  274. package/src/plugin-testing.ts +10 -0
  275. package/src/plugin-web-react.ts +4 -0
  276. package/src/plugin-web.ts +35 -0
  277. package/src/plugin.ts +126 -0
  278. package/src/router.ts +73 -0
  279. package/src/routes.ts +7 -0
  280. package/src/schemas.ts +115 -18
  281. package/src/styles/console.css +330 -1
  282. package/src/tokens.ts +101 -0
  283. package/src/vocabulary.ts +30 -0
  284. package/src/web.ts +2 -0
  285. package/template/plugin/.gitignore +2 -0
  286. package/template/plugin/README.md +26 -0
  287. package/template/plugin/SKILLCDN.md +13 -0
  288. package/template/plugin/build.mjs +40 -0
  289. package/template/plugin/console-plugin.json +50 -0
  290. package/template/plugin/package.json +27 -0
  291. package/template/plugin/skills/__key__/SKILL.md +23 -0
  292. package/template/plugin/src/check.test.ts +34 -0
  293. package/template/plugin/src/server/index.test.ts +41 -0
  294. package/template/plugin/src/server/index.ts +72 -0
  295. package/template/plugin/src/web/index.css +51 -0
  296. package/template/plugin/src/web/index.ts +115 -0
  297. package/template/plugin/tsconfig.json +17 -0
  298. package/skills/working-the-board/references/setting-up.md +0 -3
  299. /package/skills/{working-the-board → planning-the-board}/references/project-rules.md +0 -0
@@ -0,0 +1,488 @@
1
+ # Spec: plugins
2
+
3
+ - Status: **Built.** The decisions are [ADR-0049](../adr/0049-a-plugin-is-an-npm-package-installed-into-the-image-and-enabled-by-an-administrator.md) to [ADR-0054](../adr/0054-a-plugins-pages-run-in-a-sandboxed-frame-and-speak-only-to-their-plugin.md), taken 2026-11-01; milestone 34 of the [roadmap](../roadmap.md) built them in order, 2026-11-01 to 2026-11-02: the contract in the package, the authoring command, the example, the host, the installs with their consent, settings, secrets and grants, their routes and the events, the records with their versions, links and files, the record routes, export and purge, `storage:sql` with its migrations, the command's own routes with `invoke`, the endpoints, a plugin's skills as a pack of its own, `console plugins` and `console plugin <key>`, the plugins named after the rules, the board through `ctx.console`, a plugin's own events, hooks, jobs, outbound requests and calls between plugins, the frame and the bundle, the console's side of the bridge, the tabs, the sub-tabs, the widgets and the settings pages, the image's install ([deploy/README.md](../../deploy/README.md), "Plugins in the image") and the skill `building-a-plugin`. What follows is the first plugins' second pass over the contract. This document is the contract between a console and a plugin, and between the console and whoever installs or administers one; where the code and this document differ, the code is behind.
4
+ - Where things live: the contract in `@skillcdn/console/plugin` (the manifest's schema, `definePlugin` and its definers, the types of `ctx`, the capabilities, the bounds and the refusals), `@skillcdn/console/plugin/web` (the bridge client) and `@skillcdn/console/plugin/testing` (a context for a plugin's own tests); the host, the record store, the policy and the routes in `apps/console/src/plugins/`; the authoring command `console-plugin` as a second `bin` of the package, with its template in the package's `template/plugin`; an example plugin in `examples/plugin-hello`; the skill `skills/building-a-plugin`. The console's own words, limits and refusals are in [rest.md](rest.md) and [cli.md](cli.md); this document adds the plugins'.
5
+
6
+ ## Words
7
+
8
+ | Word | Meaning |
9
+ |---|---|
10
+ | plugin | One npm package that adds a feature to a console: commands and queries for agents, pages and widgets for people, records, jobs, hooks and skills; identified by its `key` |
11
+ | manifest | `console-plugin.json` at the package root: what the plugin is and asks, as data read without running anything |
12
+ | host | The console's child process that runs plugin code, one per `api` and `worker` role, with a thread per plugin |
13
+ | capability | Something a plugin asks of the console in its manifest and an administrator consents to when enabling it; the only way plugin code reaches the console |
14
+ | role | A name a plugin gives to what a person may do with it, granted by an administrator |
15
+ | board project | The project a plugin's decisions, tasks and documents go to, made or chosen when it is enabled |
16
+ | record, collection | A plugin's data, kept by the console: a collection is declared with a schema, a record is one document in it |
17
+ | command, query | Plugin code run as a person: a command writes, a query reads |
18
+ | endpoint | An inbound HTTP request a plugin accepts, by capability |
19
+ | hook, job | Plugin code the console runs itself: on an event, or on a schedule |
20
+ | frame, bridge | The sandboxed document a plugin's page runs in, and the messages between it and the console's page |
21
+ | widget | A small frame on the front page |
22
+
23
+ ## The package
24
+
25
+ ```
26
+ <package>/
27
+ package.json name, version, license, files (dist, skills, SKILLCDN.md); no install scripts
28
+ console-plugin.json the manifest
29
+ dist/server/index.js ESM; the default export is definePlugin({...})
30
+ dist/web/index.js ESM, self-contained, its assets beside it (index.css, images, fonts)
31
+ migrations/0001-*.sql with storage:sql only
32
+ skills/<name>/SKILL.md the skills, in the SkillCDN Format
33
+ SKILLCDN.md the manifest of the skills, as any pack has one
34
+ README.md, LICENSE
35
+ ```
36
+
37
+ - A plugin is installed with its lockfile and no install scripts, so everything it runs is in the package: the server module and the web bundle are built before publishing, or before the tag when it is installed from a git host (`github:<owner>/<repo>#<tag>`).
38
+ - `@skillcdn/console` is a `devDependency` of a plugin, for the types and the authoring command. Nothing of it is imported at runtime: the server module receives everything through `ctx`, and the web bundle bundles the bridge client.
39
+ - A plugin's name on the registry is its own; the console knows it by `key`.
40
+
41
+ ## The manifest
42
+
43
+ | Field | Type | Rule |
44
+ |---|---|---|
45
+ | `key` | string | `^[a-z][a-z0-9-]{1,31}$`; unique in a console; not one of the reserved words below |
46
+ | `name`, `description` | `{ [language]: string }` | `en` required; a name up to 40 characters, a description up to 200 |
47
+ | `agentNote` | `{ [language]: string }` | one sentence for agents, up to 200 characters: what the plugin is for and when to use it; printed with the rules |
48
+ | `pluginApi` | integer | the major of the plugin API the plugin was written against; `1` |
49
+ | `server` | path | the server module, relative to the package root |
50
+ | `web` | path | the directory of the web bundle, with `index.js`; required where `pages` or `widgets` are declared |
51
+ | `skills` | path | the directory of the skills; `SKILLCDN.md` is then at the package root |
52
+ | `migrations` | path | the directory of SQL migrations; with `storage:sql` only |
53
+ | `capabilities` | string[] | from the catalogue below; `outbound:<host>` and `plugins:<key>` carry their parameter |
54
+ | `roles` | `{ [role]: { name, description } }` | role keys as `key`; up to 10; names and descriptions per language |
55
+ | `defaultRole` | string | a role every member of the workspace holds without a grant; optional |
56
+ | `requires` | `{ [key]: range }` | other plugins this one needs, by a semver range of their version; they are enabled first and cannot be disabled while this one is |
57
+ | `board` | `{ needs, defaults? }` | `needs` is `required`, `optional` or `none`; `defaults` the `key`, `name` and `description` per language of the project an administrator may make for it |
58
+ | `settings` | Setting[] | up to 50, each `{ key, type, name, description?, required?, default?, min?, max?, options?, of? }`; `key` a lowercase letter followed by letters and digits (`daysPerYear`), as code and `--set` name it; types `text`, `markdown`, `number`, `boolean`, `enum` (with `options`, each `{ value, name }`), `day`, `person`, `project`, `secret`, `list` (with `of`, any type but `secret` and `list`); `min` and `max` bound a number, the length of a text or the items of a list; `default` fits the type, and `person`, `project` and `secret` have none |
59
+ | `menu` | `{ group, order?, name? }` | the tab the plugin is under: a group of the console's (`people`, `customers`, `money`, `operations`, `marketing`, `engineering`, `reports`), or one the plugin declares with a `name` per language; `order` within the row, default 100 |
60
+ | `pages` | Page[] | up to 12, each `{ key, name, level, roles?, wide? }`; `level` is `workspace` or `project`; page keys as `key`, and not `frame`, `web` or `x` |
61
+ | `widgets` | Widget[] | up to 4, each `{ key, name, place, size, roles? }`; `place` is `home:top` or `home:bottom`; `size` is `third`, `half` or `full` |
62
+ | `dataVersion` | integer | the version of the plugin's record shapes, from 1; raised with an entry in `upgrades` |
63
+ | `store` | object | `homepage`, `repository`, `keywords`, `icon`; the license is `package.json`'s |
64
+
65
+ Reserved words, which no `key` may be: the console's own paths and commands (`api`, `auth`, `plugins`, `plugin`, `settings`, `people`, `person`, `projects`, `project`, `work`, `decisions`, `decision`, `agents`, `categories`, `category`, `pages`, `connect`, `docs`, `doc`, `tasks`, `task`, `runs`, `run`, `skills`, `skill`, `members`, `member`, `rules`, `goals`, `goal`, `feed`, `files`, `file`, `events`, `frame`, `web`, `console`, `skillcdn`, `runtime`, `new`, `me`, `help`, `records`, `enable`, `disable`, `grant`, `revoke`, `secret`, `export`, `purge`). The list is `RESERVED_PLUGIN_KEYS` in `@skillcdn/console/plugin`.
66
+
67
+ An example, a plugin for leave:
68
+
69
+ ```json
70
+ {
71
+ "key": "leave",
72
+ "name": { "en": "Leave", "ko": "연차" },
73
+ "description": { "en": "Leave requests, approvals and balances.", "ko": "연차 신청, 승인, 잔여 일수." },
74
+ "agentNote": { "en": "Records leave requests and raises their approval as decisions; balances per person.", "ko": "연차 신청을 기록하고 승인을 결정으로 올립니다. 사람별 잔여 일수." },
75
+ "pluginApi": 1,
76
+ "server": "dist/server/index.js",
77
+ "web": "dist/web",
78
+ "skills": "skills",
79
+ "capabilities": ["people:read", "board:decisions", "events:console", "jobs"],
80
+ "roles": {
81
+ "employee": { "name": { "en": "Employee", "ko": "구성원" }, "description": { "en": "Requests leave and sees their own balance.", "ko": "연차를 신청하고 자기 잔여를 봅니다." } },
82
+ "approver": { "name": { "en": "Approver", "ko": "승인자" }, "description": { "en": "Sees every request.", "ko": "모든 신청을 봅니다." } }
83
+ },
84
+ "defaultRole": "employee",
85
+ "board": { "needs": "required", "defaults": { "key": "people-ops", "name": { "en": "People", "ko": "인사" }, "description": { "en": "Leave and the people's matters.", "ko": "연차와 인사 업무." } } },
86
+ "settings": [
87
+ { "key": "daysPerYear", "type": "number", "name": { "en": "Days per year", "ko": "연간 일수" }, "default": 15, "min": 0, "max": 365 },
88
+ { "key": "approver", "type": "person", "name": { "en": "Approver", "ko": "승인자" }, "required": true }
89
+ ],
90
+ "menu": { "group": "people", "order": 10 },
91
+ "pages": [
92
+ { "key": "mine", "name": { "en": "My leave", "ko": "내 연차" }, "level": "workspace", "roles": ["employee"] },
93
+ { "key": "requests", "name": { "en": "Requests", "ko": "신청" }, "level": "workspace", "roles": ["approver"], "wide": true }
94
+ ],
95
+ "widgets": [{ "key": "balance", "name": { "en": "Leave left", "ko": "남은 연차" }, "place": "home:top", "size": "third", "roles": ["employee"] }],
96
+ "dataVersion": 1
97
+ }
98
+ ```
99
+
100
+ ## The server module
101
+
102
+ The default export of `server` is `definePlugin({...})` from `@skillcdn/console/plugin`. Every definer yields a description the host can serialize (names, arguments, roles, schedules) beside the functions it runs; the console parses command lines, writes help and draws forms from the description, never from the code.
103
+
104
+ ```ts
105
+ import { definePlugin, defineCollection, defineCommand, defineQuery, defineEndpoint, arg, z } from "@skillcdn/console/plugin";
106
+
107
+ const requests = defineCollection("requests", {
108
+ schema: z.object({ personId: z.string(), from: z.string(), to: z.string(), reason: z.string().max(2000), state: z.enum(["requested", "approved", "rejected", "cancelled"]), decisionId: z.string().nullable() }),
109
+ indexes: ["personId", "state", "from"],
110
+ search: ["reason"],
111
+ owner: "personId",
112
+ project: "none",
113
+ read: ["approver"],
114
+ readOwn: ["employee"],
115
+ });
116
+
117
+ export default definePlugin({
118
+ collections: [requests],
119
+ commands: {
120
+ request: defineCommand({
121
+ about: "Requests leave for the person the command acts as; the approval is a decision.",
122
+ args: { from: arg.day({ about: "the first day" }), to: arg.day({ about: "the last day" }), reason: arg.text({ optional: true, max: 2000 }) },
123
+ roles: ["employee"],
124
+ async run(ctx, args) {
125
+ const request = await ctx.records(requests).create({ personId: ctx.caller.kind === "person" ? ctx.caller.person.id : "", from: args.from, to: args.to, reason: args.reason ?? "", state: "requested", decisionId: null });
126
+ /* ... */
127
+ return { text: "Requested; the decision waits for the approver.", data: request, waiting: decision.id };
128
+ },
129
+ }),
130
+ },
131
+ queries: {
132
+ balance: defineQuery({ about: "The days left this year.", args: { person: arg.person({ optional: true }) }, roles: ["employee"], async run(ctx, args) { /* ... */ } }),
133
+ },
134
+ endpoints: {},
135
+ on: {
136
+ "console:decision.answered": async (ctx, event) => { /* event.data.optionKey says which option; about maps it to the request */ },
137
+ },
138
+ jobs: {
139
+ accrue: { about: "Adds the year's days on its first day.", every: "P1D", at: "02:00", async run(ctx) { /* ... */ } },
140
+ },
141
+ upgrades: [],
142
+ api: {},
143
+ async onEnable(ctx) {},
144
+ async onDisable(ctx) {},
145
+ });
146
+ ```
147
+
148
+ | Part | Shape | Notes |
149
+ |---|---|---|
150
+ | `collections` | `[defineCollection(name, { schema, indexes?, search?, owner?, project?, read?, readOwn?, history? })]` | a list; `name` as `key`; `schema` is a zod object (any schema that follows Standard Schema), checked in the plugin's thread on every write; `indexes` (up to 8) are the fields `where`, `order` and `aggregate` may name, beside `id`, `owner` and `project`, which always may; `search` the fields searched as words; `owner` an indexed field holding a person's id, for `readOwn`; `project` is `none` (unless said), `optional` or `required`; `read` and `readOwn` name roles; `history` keeps a version per write, `true` unless said otherwise; up to 50 collections. The definition is what `ctx.records(requests)` takes, and types the records from the schema |
151
+ | `commands` | `defineCommand({ about, args, roles?, run })` | `about` one line; `args` named definers, positional where `positional: true`, in declaration order; `run(ctx, args)` returns `{ text, data?, waiting? }`: `text` is what the command prints, `data` what `--json` prints, `waiting` the id of a decision the caller waits for; up to 100 commands and queries together, named as `key` and not as a subcommand of the command (`help`, `records`, `skills`, `skill`, `enable`, `disable`, `settings`, `secret`, `grant`, `revoke`) |
152
+ | `queries` | `defineQuery({ about, args, roles?, run })` | reads only; `run` returns JSON up to 4 MiB, or `{ file: { name, type, bytes } }` for an export |
153
+ | `endpoints` | `defineEndpoint({ auth, roles?, secret?, methods?, run })` | with the `endpoints` capability; `auth` is `person` (a session or a token, with `roles`) or `webhook` (nobody; `secret` names the setting the plugin verifies the signature with); `run(ctx, request)`, the request `{ method, path, query, headers, body }` with the headers `content-type` and those the definition names and the body text up to 1 MiB, returns `{ status, headers?, body? }`, the headers among `content-type`, `location` and `cache-control`, the body a string or JSON up to 4 MiB |
154
+ | `on` | `{ "console:<kind>": handler, "plugin:<key>:<name>": handler }` | hooks; `console:` kinds need `events:console`, `plugin:` ones `events:plugins`; `handler(ctx, event)`; idempotent, since delivery is at least once |
155
+ | `jobs` | `{ [name]: { about, every, at?, run } }` | `every` an ISO 8601 duration of five minutes or more; `at` a time of day (`HH:MM`) in the workspace's time zone, for a daily or longer `every`; one run of a job at a time |
156
+ | `upgrades` | `[{ to, about, run }]` | run in order by the `migrate` role from the installed `dataVersion` to the manifest's |
157
+ | `api` | `{ [name]: { about, input, run } }` | what other plugins may call with `plugins:<key>`; `input` a zod schema; `run(ctx, input)` with the caller passed through |
158
+ | `onEnable`, `onDisable` | `(ctx) => Promise<void>` | optional; run when an administrator enables or disables |
159
+
160
+ The argument definers: `arg.text({ max?, optional?, secret? })`, `arg.markdown()`, `arg.day()`, `arg.number({ min?, max?, integer? })`, `arg.boolean()`, `arg.enum([...])`, `arg.person()` (a login or `me`, resolved by the console to a person the caller may see, handed to `run` as `{ id, login, name }`), `arg.project()` (a key the caller may see, handed as `{ id, key, name }`), `arg.record("<collection>")` (an id of that collection), `arg.list(inner, { max? })` (a flag repeated, or commas between), `arg.file()` (what `--file <path>` or `-` carried; the command uploads it first and hands a handle). Every definer takes `about` (one line, shown in help), `optional` and `positional`; a positional argument comes before the flags, in declaration order, and no required one follows an optional one. Each yields a plain description, and `run`'s `args` is typed from them: optional ones may be `undefined`. An argument marked `secret` is hidden in the event the command leaves. An argument is not named `json`, `url`, `account`, `project`, `file` or `run`, the flags the command keeps for itself (`RESERVED_ARG_NAMES`). A plugin refuses in its own words by throwing `PluginError(code, message)`, which the console answers as `plugin.<key>.<code>`; a definition the console cannot hold is refused when the module loads, as a `PluginDefinitionError` naming every problem.
161
+
162
+ ## The context
163
+
164
+ Everything a plugin does to the console is a call on `ctx`, which is a message to the parent process; arguments and results are JSON, files are handles, nothing else crosses. The parent decides each call by the capabilities consented to and, for a person's call, by the person's roles and the console's own policy, and refuses what is not allowed with `plugin.forbidden`.
165
+
166
+ | Member | What it is | Needs |
167
+ |---|---|---|
168
+ | `ctx.plugin` | `{ key, version, dataVersion, settings, board, projects }`: the settings without the secrets; `board` the board project's `{ id, key }` or `null`; `projects` `"all"` or the keys of the projects the plugin is on | nothing |
169
+ | `ctx.caller` | `{ kind: "person" \| "console", person?, agent?, roles, run?, project? }`: in a command, a query or a person's endpoint, the person (`id`, `login`, `name`, `language`, `admin`), the agent's name where a token acts, their roles in this plugin, their open run in the board project if any, and the project the call is on; in a job, a hook, an upgrade or a webhook, `console`, with every role | nothing |
170
+ | `ctx.records(collection)` | the records of a collection, by its definition (typed from the schema) or by name: `create(data, { project?, owner? })`, `get(id)` (`null` when there is none), `update(id, patch, { baseVersion? })`, `archive(id)`, `restore(id)`, `list({ where?, order?, limit?, after?, project?, archived? })`, `search(words, { where?, limit?, project? })`, `aggregate({ where?, groupBy?, count?, sum?, min?, max? })`, `versions(id)`, `link(id, target)`, `unlink(id, target)`, `links(id)`, and `files(id)` with `attach(handle, { name?, type? })`, `list()` and `remove(fileId)`; `order` is `{ field, descending? }`; a refusal is thrown as a `PluginRefusal` with the console's code | nothing; `files` for the files |
171
+ | `ctx.sql` | `query(text, params)` and `transaction(async (tx) => ...)`, on the plugin's own role | `storage:sql` |
172
+ | `ctx.console.workspace()` | the workspace's name, languages, time zone and categories | nothing |
173
+ | `ctx.console.people` | `list({ q? })` and `get(id)`: the people the caller may see; everyone for a `console` caller, or with `people:read:all` | `people:read` |
174
+ | `ctx.console.projects` | `list()`, `get(key)`, `members(key)`: the projects the caller may see; all with `projects:read:all` | `projects:read` |
175
+ | `ctx.console.tasks` | `list(project, filters)`, `get(project, number)`, `create(project, task)`, `update(project, number, patch)`, `comment(project, number, body)`: reading in the plugin's projects, creating there, changing and commenting its own | `board:read`; `board:tasks` |
176
+ | `ctx.console.decisions` | `list(project)`, `get(id)`, `ask(project, { question, body?, options, deciderId?, taskId?, about })`: `options` each `{ label, key? }`, the `key` spelled as a plugin's key is and given once, answered back as `optionKey` on `console:decision.answered`; `about` names the record the decision is for; there is no `answer` | `board:read`; `board:decisions` |
177
+ | `ctx.console.documents` | `list(project, folder)`, `read(project, path)`, `write(project, path, { title, body })` under `plugins/<key>/` | `board:read`; `board:documents` |
178
+ | `ctx.console.runs` | `list(project)`, `get(id)` | `board:read` |
179
+ | `ctx.events.emit(kind, data)` | an event of the plugin's own, for its frames and for other plugins' hooks; `kind` as `key`, `data` JSON up to 16 KiB | nothing |
180
+ | `ctx.fetch(url, init)` | an outbound request; `init` may carry `secret: { name, as }`, `as` one of `header:<name>`, `query:<name>` and `basic`, and the console puts the secret there | `outbound:<host>` for the URL's host |
181
+ | `ctx.secrets` | `has(name)`, `sign(name, payload, algorithm)`, `verify(name, payload, signature, algorithm)` with `hmac-sha256`; never the value | nothing |
182
+ | `ctx.plugins.get(key)` | `{ version, api: { [name]: (input) => Promise<unknown> } }` of another plugin, the caller passed through | `plugins:<key>` |
183
+ | `ctx.log` | `info`, `warn` and `error` lines, written to the console's log with the plugin's key and the caller; up to 100 lines a minute | nothing |
184
+ | `ctx.now()`, `ctx.language`, `ctx.signal` | the time; the caller's language, `en` for `console`; the `AbortSignal` that fires at the bound | nothing |
185
+
186
+ What `ctx` never has, whatever the manifest asks: the environment, a connection of the console's to the database, a session or a token, a secret's value, another plugin's records or secrets, a way to answer a decision, to change a person, a role, a membership or a setting of the workspace, or to make a request a person could not make themselves.
187
+
188
+ A read of the console in a person's call is the person's: `people.list` is the people they may see, `projects.list` the projects they may see, a task they may not see is not found. A `console` caller (a job, a hook) sees what the plugin's capabilities open: `people:read` alone shows a job nobody, `people:read:all` everyone. A plugin that wants a person to see more than they may must not; the capability for the whole directory is declared and consented to as such.
189
+
190
+ ## Capabilities
191
+
192
+ Declared in the manifest, consented to by an administrator when enabling, enforced by the parent on every call. Without a capability, the call is `plugin.forbidden`. The last column is the reach of a plugin that turns out malicious and holds the capability; what no capability opens is under "Security model".
193
+
194
+ | Capability | Opens | Reach, if malicious |
195
+ |---|---|---|
196
+ | (none) | its own records, versions, links and events; its settings; its log | its own data, spoiled; the versions restore it |
197
+ | `files` | files on its records, within `PLUGIN_FILE_BYTES_AT_MOST` | storage filled to the bound |
198
+ | `people:read` | the people the caller may see, as the person | the names and logins its role holders may see anyway |
199
+ | `people:read:all` | everyone, for `console` callers too; and the workspace's people events (`person.joined`, `person.deactivated`, `person.reactivated`, `person.role_changed`, `person.listed`, `person.unlisted`) to its hooks, with their content | the directory, and when people come and go |
200
+ | `projects:read` | the projects and members the caller may see | what the person sees |
201
+ | `projects:read:all` | every project and its members | the projects and who is in them |
202
+ | `board:read` | the tasks, decisions, runs, documents and events of its board projects, and of the project a call is on | the content of those boards, to its role holders |
203
+ | `board:tasks` | tasks created in its projects; its own changed, closed, commented | tasks that mislead; each marked as the plugin's, put away by anyone |
204
+ | `board:decisions` | decisions raised in its projects; the answers to its own read | questions that mislead; never an answer |
205
+ | `board:documents` | pages written under `plugins/<key>/` in its projects | pages that mislead, in its folder |
206
+ | `events:console` | hooks on the console's events of its board projects: the kind, the ids and the time, the content with `board:read` | when things happen on those boards |
207
+ | `events:plugins` | hooks on the events other plugins emit | what they publish |
208
+ | `jobs` | runs on a schedule | time, within the bound |
209
+ | `outbound:<host>` | `ctx.fetch` to that host, a public address, over https | what it may read, sent to that host; a cost there |
210
+ | `endpoints` | inbound requests under its path, a person's or a webhook's | input it accepts without verifying; the rate limit holds |
211
+ | `plugins:<key>` | that plugin's `api`, as the caller | what that API gives the caller |
212
+ | `storage:sql` | a schema and a login role of its own | statements up to the timeout, within its schema |
213
+
214
+ `outbound:<host>` names a host exactly or as `*.example.com`; the console resolves the host before connecting and refuses its own networks, as it does for a picture ([ADR-0048](../adr/0048-a-picture-from-elsewhere-is-read-through-the-console-from-a-public-address-and-kept-a-while.md)). A new version of a plugin that declares a capability its install was not consented to waits, disabled, as `needs consent`, until an administrator consents again.
215
+
216
+ ## Roles and grants
217
+
218
+ - A plugin declares roles; what each means is the plugin's to say in its description and to enforce where it reads, since the console knows a role by its name alone.
219
+ - A grant is an administrator's: a role to a person, to the members of a project (a grant that follows the membership), or to everyone. `defaultRole` is everyone's without a grant. An administrator holds every role of every plugin.
220
+ - A command, query, page, widget or endpoint names the roles that may use it (any of them); without `roles`, every member may. A collection names the roles that read it, and those that read their own records in it.
221
+ - A token acts with its person's roles; nothing is granted to an agent ([ADR-0021](../adr/0021-a-token-configures-as-its-person-does.md)).
222
+ - A person without a role in a plugin sees none of its tabs or widgets, and its pages and commands are `plugin.forbidden`; the plugin itself is listed, so that the person knows what to ask for.
223
+
224
+ ## Settings
225
+
226
+ The form on `/settings/plugins/<key>` and `console plugin <key> settings --set` come from the manifest's `settings`. A `secret` is sealed with the deployment's secret (`AUTH_SECRET`, with the purpose `plugin-secret`), never returned by any route or shown in any form (only whether and when it was set), and used by the console where the plugin names it: `ctx.fetch`'s `secret`, `ctx.secrets.sign` and `verify`, a webhook's `secret`. A deployment without `AUTH_SECRET` refuses settings of type `secret`. `person` and `project` settings hold ids and are shown as names, chosen on the page among the workspace's people and projects; a person who left or a project put away makes the setting invalid, which the pages show. Settings are the workspace's; a plugin that needs a value per project keeps a record linked to the project.
227
+
228
+ ## Records
229
+
230
+ The console keeps a plugin's records in `plugin_records` (id UUIDv7, plugin, collection, project, owner, data, version, created and updated by whom and through what, archived), `plugin_record_versions`, `plugin_record_files` (a file in the blob store, by content hash) and `plugin_links` (a record to a task, a decision, a document, a project, a run, or a record of any plugin; and the board's items a plugin made, with no record).
231
+
232
+ - `where` is `{ field: value | { in: [...] } | { gte, gt, lte, lt } | { exists } }` on `indexes` fields, several combined with and; `order` one `indexes` field, ascending or descending, the id as the tie; pages by `limit` (up to 200) and `after` (the cursor the last page returned). `search` is the database's text search over the `search` fields, as documents are searched. `aggregate` groups by up to two `indexes` fields and counts, sums, takes the least and the most of numeric fields.
233
+ - A write checks the schema, bounds the record to 64 KiB and the collection to the plugin's quota, and keeps the version before it where `history` is on; `update` with `baseVersion` is `plugin.conflict` when the record moved on. `archive` keeps a record out of lists; nothing is deleted but by purge.
234
+ - Who reads a record through the console's generic routes and the bridge: a person with a `read` role of the collection, or a `readOwn` role where the record's `owner` is them; and, where the record is linked to a project, only if they may see the project. Plugin code reads its own records whole; what it shows is its own responsibility, `ctx.caller` saying who asks.
235
+ - Files: `POST .../files` takes bytes from a person's page or from the command and answers a handle good for an hour; a command attaches the handle to a record, and the console counts the bytes to the plugin's bound. `GET .../files/<id>` is read under the record's rule.
236
+ - Export: `GET .../export` is JSON Lines, a line per record, version, link, grant and setting, then the list of the files with their hashes; an administrator's.
237
+
238
+ ## SQL
239
+
240
+ With `storage:sql` consented to, enabling creates the schema `plugin_<key>` and the login role `plugin_<key>`, grants the role `USAGE` and `CREATE` on that schema and nothing on any other, sets its `search_path` to the schema, its `statement_timeout` to ten seconds and its connection limit to four, and gives it a password the console draws at every boot and keeps in memory alone. The plugin's `migrations/*.sql`, in the order of their names, are applied as the plugin's role by the `migrate` role under the console's migration lock, those applied recorded in `plugin_<key>._migrations`. `ctx.sql.query` and `transaction` run on a pool of the plugin's role in the parent, and a result is bounded to 10,000 rows and 4 MiB. The console's own tables are in `public`, where the role holds no right. Purge drops the schema with everything in it, and the role. The console's database user needs `CREATEROLE`; without it, `GET /api/v1/plugins` says `storage:sql` is unavailable in this deployment, and a plugin that declares it cannot be enabled (`plugin.unavailable`).
241
+
242
+ ## The board
243
+
244
+ - `board.needs` says whether the plugin puts things on a board. Enabling one with `required` or `optional`, the administrator makes a project from `board.defaults` (the key, name and description in the workspace's languages, edited as they like; private; they are its owner) or chooses one that exists; several plugins may bind to one project. The install keeps the project's id, and the project's settings page shows the plugins bound to it.
245
+ - A plugin with `level: project` pages, or with `project: optional` or `required` collections, is on the projects the administrator names: all, or a list. A plugin on no project has no project pages. Owners do not enable plugins in their projects.
246
+ - What a plugin makes on a board carries its name: the console writes the link (`plugin_links`: plugin, kind, id) when `ctx.console.tasks.create`, `decisions.ask` or `documents.write` runs, the event carries `plugin = <key>` beside `actor` and `agent`, and the pages and the feed say "through <plugin>" (`plugin` on a task, a decision and a document as the API answers them). A person's call acts as the person; a `console` caller, a job or a hook, acts as the administrator who enabled the plugin, who owns what it makes, within the plugin's projects alone. A plugin changes, closes, comments or reads the answer of its own items only; it reads every item of its board projects with `board:read`. Its documents go under `plugins/<key>/`; a write elsewhere is `plugin.forbidden`.
247
+ - A decision a plugin raises is a decision as any ([ADR-0042](../adr/0042-a-decision-is-addressed-to-a-person-its-raiser-unless-another-is-named.md)): addressed to the person named in `deciderId`, who must be able to work in the project, else to the project's owners; answered by anyone of the project; shown among the person's decisions and in `console work`. The command that raised it returns `waiting` and exits with `3`, and the agent waits as it does for `ask`. The plugin learns of the answer through `on["console:decision.answered"]`, which `about` lets it map to its record, and whose `data` names the chosen option by its label (`option`) and by the key the plugin gave it (`optionKey`), for its own decisions with `board:decisions` alone, so that it reads the answer without comparing labels or asking again; the person's own answer has no key. A plugin never answers.
248
+ - Where a record is linked to a project and the caller may work there, `ask` and `tasks.create` may name that project instead of the board project.
249
+ - Two plugins on one project do not collide: each edits its own, reads all, writes in its own folder; one refers to another's work by reading, or through the other's `api`.
250
+
251
+ ## Events, hooks and jobs
252
+
253
+ - New kinds of the console's events: `plugin.enabled`, `plugin.disabled`, `plugin.configured`, `plugin.granted`, `plugin.revoked`, `plugin.upgraded`, `plugin.command`, `plugin.failed`, `plugin.purged`. `events.plugin` names the plugin on every event a plugin caused, its own kinds and the board's. `plugin.command` records the plugin, the command, the arguments (the secret ones as `[hidden]`), the outcome (`ok`, `refused` with its code, `waiting`, `failed`), the duration and the caller's run. They are in the board project's feed where there is one, and always at `GET /api/v1/plugins/<key>/events`.
254
+ - A plugin's own events (`ctx.events.emit`, the kind named as a `key`, the data JSON up to 16 KiB) go to `plugin_events` (plugin, kind, data, at; 100,000 kept, for ninety days, the janitor sweeping the rest), reach the frames of that plugin that subscribed, read at `GET .../plugin-events?after=&limit=` by its role holders, and the hooks of plugins with `events:plugins` on `plugin:<key>:<kind>`. They are not the board's feed.
255
+ - Hooks run in the `worker` role's host. A dispatcher reads the console's events and `plugin_events` from a cursor per plugin (`plugin_cursors`, begun at the end of both streams when the plugin is enabled, so that it is not told of everything that ever happened) under an advisory lock, so that one process dispatches, and delivers each event to the handler that named its kind; a console event is delivered for the plugin's board project, the projects it is on, the items it made and what it caused, and, for a plugin with `people:read:all`, the workspace's people events (`person.*`), with the kind, the ids and the time, and the content and the actor with `board:read`, or with `people:read:all` for a people event, since the directory is its to read. Delivery is at least once and in order: a handler that fails is retried five times with doubling backoff, then the event is skipped with a `plugin.failed` event; a handler has thirty seconds.
256
+ - Jobs run in the `worker` role's host, with the `jobs` capability, each under an advisory lock, never two at once; `plugin_jobs` keeps the last run, its outcome and the next time, the first run one period after the scheduler first sees the job, `at` read in the workspace's time zone; a failed job is a `plugin.failed` event, at most one an hour per job, and a line in the log every time. A job has five minutes.
257
+ - `WORKER_IN_PROCESS` puts hooks and jobs in the `api` role's host.
258
+
259
+ ## Between plugins
260
+
261
+ - `requires` is enabled first and stays enabled: enabling a plugin whose requirement is not enabled, or not in range, is `plugin.requires`; disabling one that another enabled plugin requires is `plugin.required_by`.
262
+ - `plugins:<key>` lets a plugin call `ctx.plugins.get(key).api.<name>(input)`; the callee checks `input` with its schema and runs with the caller passed through, so that the callee's roles decide again; a callee that is disabled is `plugin.disabled` to the caller.
263
+ - `events:plugins` is the loose coupling: a plugin emits, another listens; a payload is the emitter's to define and document.
264
+
265
+ ## Lifecycle
266
+
267
+ | State | How it comes about | What works |
268
+ |---|---|---|
269
+ | installed | the package is in `PLUGINS_DIR` with a manifest, and loads | listed in the settings; nothing else |
270
+ | failed | the manifest is invalid, the key is taken, `pluginApi` is not served, `requires` names a plugin that is missing, the server module does not load, or the installed `dataVersion` is newer than the code's | listed with its reasons; a `plugin.failed` event; the console boots on |
271
+ | enabled | an administrator consented to the capabilities, bound the board, named the projects and filled the required settings, and `onEnable` ran | everything |
272
+ | needs consent | a new version asks capabilities the install lacks | nothing but the settings, until consented |
273
+ | disabled | an administrator disabled it and `onDisable` ran, or a required plugin is gone | nothing; the data kept; the tabs gone; commands `plugin.disabled` |
274
+ | orphaned | the package left the directory while the install stays | nothing; the settings offer export and purge |
275
+ | purged | an administrator purged it, naming the key again | nothing remains but the board's items, marked |
276
+
277
+ Upgrades: the `migrate` role, before the `api` and `worker` roles start on a new image, runs each enabled plugin's SQL migrations and `upgrades` from the installed versions to the manifest's, in a host of its own, under the console's migration lock. `GET /readyz` is the console's; a plugin's state is on `GET /api/v1/plugins`.
278
+
279
+ ## The host
280
+
281
+ - The `api` and `worker` roles each spawn one child process from the console's own code (`dist/plugin-host.js`), as `node --permission --allow-fs-read=<PLUGINS_DIR>,<each plugin's directory>,<the host's own directory> --allow-worker dist/plugin-host.js`, with an environment of nothing but what Node needs, no database credentials, and no configuration but what the parent sends over the IPC channel: the plugins to load and the limits. A plugin's directory is its real path, so a package linked into `PLUGINS_DIR` from a checkout, as it is while a plugin is written, is read where it is. No `--allow-fs-write`, `--allow-child-process`, `--allow-addons` or `--allow-wasi`.
282
+ - The host loads each plugin's server module in a worker thread of its own, with `resourceLimits` from `PLUGIN_MEMORY_MB_AT_MOST`, after hardening the thread: a module hook refuses `node:net`, `node:http`, `node:https`, `node:http2`, `node:tls`, `node:dgram`, `node:dns`, `node:child_process`, `node:cluster`, `node:worker_threads`, `node:vm`, `node:inspector`, `node:repl`, `node:wasi` and `node:v8`; `globalThis.fetch`, `WebSocket`, `EventSource` and `process.binding` are removed; `process.env` is emptied. Reading files is the plugin's own directory, by the permission model; writing is nowhere.
283
+ - The host's main thread is the broker: a call on `ctx` is a message from the thread to the broker to the parent, which decides and performs it and answers; messages are JSON up to 4 MiB, and files travel as handles the parent resolves.
284
+ - A thread that runs past its time (`ctx.signal` fires first, then the broker ends it), grows past its memory or crashes is ended alone; the broker starts it afresh, and the call that was running fails as `plugin.timeout` or `plugin.unavailable`. Module-level state does not survive; state is in records.
285
+ - A host that dies is restarted by its parent; while it is away, every plugin call is `plugin.unavailable`, and the console answers everything else.
286
+ - The `migrate` role spawns a host to run upgrades and stops it. `WORKER_IN_PROCESS` means one host.
287
+
288
+ ## REST
289
+
290
+ Under `/api/v1/plugins`. Who may call: *anyone* signed in or on a token, *roles* a person holding a role the thing names, *admin* an administrator. The shapes of these routes are in `@skillcdn/console/api` and in the REST snapshot ([ADR-0043](../adr/0043-the-rest-contract-is-held-to-a-snapshot-taken-at-the-floors.md)); what a plugin's command or query carries is the plugin's.
291
+
292
+ | Route | Who | What |
293
+ |---|---|---|
294
+ | `GET /api/v1/plugins` | anyone | the plugins a member may know of (the enabled ones; every state for an administrator): key, name, description, version, state and reasons, the caller's roles, the menu, the pages and widgets the caller may see, the board project's key; and whether `storage:sql` is available in this deployment |
295
+ | `GET /api/v1/plugins/<key>` | anyone | one plugin: the above, its commands and queries (name, about, args), its skills address, its settings without the secrets (whether each is set); for an administrator the consent, the grants and the projects |
296
+ | `POST .../enable` | admin | `{ consent: [...], board: { create: { key, name, description } } \| { project: "<key>" } \| null, projects: "all" \| [...], settings: {...} }` |
297
+ | `POST .../disable` | admin | |
298
+ | `PATCH .../settings` | admin | `{ values }`, without secrets |
299
+ | `PUT .../secrets/<name>`, `DELETE .../secrets/<name>` | admin | `{ value }`; never read |
300
+ | `GET .../grants`, `POST .../grants`, `DELETE .../grants/<id>` | admin | `{ role, person?: id, project?: key, everyone?: true }` |
301
+ | `POST .../invoke` | roles | `{ argv: [...], files?: [handles], project?: key, run?: id }`: the words as typed, the command's or the query's name first, parsed by the console against the definition (`parseArgv`: the positionals in declaration order, `--name value` or `--name=value`, a boolean flag alone, a list as the flag repeated or with commas between, `--` ending the flags; a person by login or `me`, a project by key and a file by its handle resolved to what the caller may see or uploaded); the handles of the files uploaded for its `file` arguments, in declaration order; the project the call is on; the run it is for, one of the caller's open in the board project, where several are. `{ kind: "command" \| "query", text, data, waiting }`, a query's file as `data.file` with `name`, `type`, `bytes` and `base64`; or the refusal |
302
+ | `POST .../commands/<name>` | roles | `{ args, project?, run? }`, the arguments as JSON by name, for the pages; the same answer |
303
+ | `GET .../queries/<name>?args=<json>&project=` | roles | the query's JSON, or its file as bytes with `content-disposition` |
304
+ | `GET .../records/<collection>?where=&order=&limit=&after=&q=&project=` | roles | the records the caller may read |
305
+ | `GET .../records/<collection>/<id>[?version=]`, `GET .../records/<collection>/<id>/versions` | roles | one record, a version of it, its versions |
306
+ | `POST .../files`, `GET .../files/<id>` | roles | a file for a command (a handle, good for an hour); a record's file |
307
+ | `GET .../events?limit=&offset=` | roles | the plugin's events |
308
+ | `GET .../plugin-events?after=&limit=` | roles | the events the plugin emitted (`ctx.events.emit`), after a number, oldest first: `{ items: [{ id, kind, data, at }], more }` |
309
+ | `GET .../skills`, `.../skills/one?path=&cursor=`, `.../skills/file?path=&offset=&limit=`, `.../skills/browse?path=&cursor=&limit=`, `.../skills/find?query=&path=&cursor=&limit=` | anyone who may know of the plugin | the plugin's skills, a pack of its own at `/local/plugins/<key>`, read from its package on the console's machine as a `local` pack is ([ADR-0031](../adr/0031-the-console-reads-a-pack-as-the-skillcdn-command-does-and-a-local-one-from-a-directory.md)), answered as a project's five reads are; `status: none` for a plugin that ships none |
310
+ | `ANY .../x/<name>[/<rest>]` | a person, or nobody | an endpoint, with `endpoints`: a `person` endpoint for whoever is signed in or presents a token and holds a role it names (a session's request that is not a read, from elsewhere than the console's own pages, counts for nobody), a `webhook` endpoint for nobody, run as the console with every role and held to 600 requests a minute per plugin (`plugin.rate_limited`). The request's body up to 1 MiB, its headers cut to `content-type` and those the definition names; the answer's status, its `content-type`, `location` and `cache-control`, and its body, under the console's own `nosniff`, `no-store` and a sandboxing policy. A method the definition does not name is `plugin.invalid_args` |
311
+ | `GET .../export` | admin | JSON Lines, and the list of the files |
312
+ | `POST .../purge` | admin | `{ confirm: "<key>" }` |
313
+ | `GET .../frame?page=<key>\|widget=<key>&project=<key>` | anyone, no session | the frame document |
314
+ | `GET .../web/<version>/<path>` | anyone, no session | a file of the bundle, immutable |
315
+ | `GET /api/v1/plugin-runtime/console.css` | anyone, no session | the console's stylesheet, for frames |
316
+ | `GET /api/v1/plugin-runtime/fonts.css` | anyone, no session | the faces of the console's own build, for frames; empty where the server has no build |
317
+ | `GET /api/v1/plugin-runtime/assets/<file>` | anyone, no session | a font of the console's build, immutable |
318
+
319
+ ## The command
320
+
321
+ `console plugins` and `console plugin <key> …` are two entries of the command's table; the command sends words and renders the generic shapes, and knows nothing of any plugin. `console help` gains one line: the plugins of this console, if any, are `console plugins`, and one of them `console plugin <key>`.
322
+
323
+ | Command | Asks | Says |
324
+ |---|---|---|
325
+ | `plugins` | `GET /api/v1/plugins` | a line per plugin: key, name, version, state, the caller's roles |
326
+ | `plugin <key>` | `GET /api/v1/plugins/<key>` | the description and the agent note; a line per command and query; the skills (`console plugin <key> skills`); the board project; the settings an agent should know |
327
+ | `plugin <key> help [<command>]` | the same | one command whole, in the form of `console help <command>`: the forms, the arguments, the limits, the refusals |
328
+ | `plugin <key> <command> [args] [--<flag> <value>]... [--file <path> \| -] [--run <id>] [--json]` | `POST .../files` where `--file` or `-`, then `POST .../invoke` with the words as typed, the handle, the project the command works in and the run named | the command's `text`, or its `data` with `--json`; a query's answer as JSON, a file it answers saved under its name; exit `0`; `3` with `waiting`, printing the decision's id and `console decision <id> --wait`; `1` on a refusal, in the console's form. `--json`, `--url`, `--account`, `--project`, `--file` and `--run` are the command's own wherever they stand; every other word is the plugin's |
329
+ | `plugin <key> records <collection> [<id>] [--where <field>=<value>]... [--search <words>] [--project <key>] [--order <field>[:desc]] [--limit <n>] [--after <cursor>] [--archived] [--versions]` | `GET .../records/...` | the records, one a line, or one whole, or its versions |
330
+ | `plugin <key> skills [--search <words>]`, `plugin <key> skill <path>` | `GET .../skills...` | as `console skills` and `console skill` |
331
+ | `plugin <key> enable [--consent <capability>,...] [--board new\|<key>] [--projects all\|<key>,...] [--set <key>=<value>]...`, `plugin <key> disable`, `plugin <key> settings [--set ...]`, `plugin <key> secret <name>` (the value from standard input), `plugin <key> grant <role> --person <login>\|--project <key>\|--everyone`, `plugin <key> revoke <grant>` | the administrator's routes | what changed; `enable` prints what each capability opens before asking the console, and refuses without `--consent` naming every capability the manifest asks |
332
+
333
+ The rules an agent reads carry the plugins: `GET /api/v1/rules` and `GET /api/v1/projects/<key>/rules` answer `plugins`, each with its `key`, `name`, `agentNote` and `description` by language and the caller's `roles` in it, and `console take` and `console rules` print, after the project's rules, a section `## Plugins` with a line per plugin bound to the project (as its board project, or on it) that the caller holds a role in, `- <key> (<name>): <agentNote> Start with console plugin <key>.`, the note in the caller's language where the manifest has it, the description where it has no note; `console rules` at the workspace lists every plugin the caller may use. Nothing else of a plugin reaches an agent unasked. `console help` names the two commands in a section of their own, and `console skills` ends with where a plugin's skills are. The skill `working-the-board` says: when the rules name a plugin, read `console plugin <key>` and its skills before its first command.
334
+
335
+ ## Pages
336
+
337
+ ### The frame
338
+
339
+ `GET /api/v1/plugins/<key>/frame?page=<key>` (or `widget=<key>`), with `&project=<key>` on a project, answers a document the console writes, with no session needed and nothing a session would add:
340
+
341
+ ```html
342
+ <!doctype html>
343
+ <html lang="<language>" data-console="<origin>" data-plugin="<key>" data-page="<key>">
344
+ <head>
345
+ <meta charset="utf-8">
346
+ <link rel="stylesheet" href="<origin>/api/v1/plugin-runtime/fonts.css">
347
+ <link rel="stylesheet" href="<origin>/api/v1/plugin-runtime/console.css">
348
+ <link rel="stylesheet" href="<origin>/api/v1/plugins/<key>/web/<version>/index.css">
349
+ </head>
350
+ <body><div id="root"></div><script type="module" src="<origin>/api/v1/plugins/<key>/web/<version>/index.js"></script></body>
351
+ </html>
352
+ ```
353
+
354
+ with the headers `content-security-policy: default-src 'none'; script-src <origin>/api/v1/plugins/<key>/web/; style-src <origin>/api/v1/plugins/<key>/web/ <origin>/api/v1/plugin-runtime/; img-src <origin>/api/v1/plugins/<key>/web/ data: blob:; font-src <origin>/api/v1/plugins/<key>/web/ <origin>/api/v1/plugin-runtime/; connect-src 'none'; frame-ancestors <origin>; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-forms`, `referrer-policy: no-referrer`, `x-content-type-options: nosniff` and `cache-control: no-store`. `<origin>` is the origin of the page that embeds the frame, read from the request's `referer`: `PUBLIC_URL`'s, or the loopback a person's own console is served on by `console serve` ([ADR-0014](../adr/0014-a-persons-own-console-is-served-from-their-machine-by-the-command.md)); any other, or none, is not found. The console's page embeds the frame as `<iframe sandbox="allow-scripts allow-forms" referrerpolicy="origin" src=...>`. The document's origin is opaque: no cookie travels, the API is out of reach, and its scripts are the bundle's alone. `allow-forms` is there so that a page's form fires its `submit` event, which a browser drops with the submission otherwise; `form-action 'none'` keeps a submission from going anywhere, so a page handles the event itself and speaks over the bridge. The headers are proven by an integration test, and a browser is seen to enforce them by `pnpm test:browser`, a real Chromium against the console listening on this machine, apart from `pnpm check` (`apps/console/src/http/plugin-frame.browser.test.ts`). The files of the bundle, `GET .../web/<version>/<path>`, are served from the package's `web` directory with `cache-control: public, max-age=31536000, immutable`, `cross-origin-resource-policy: cross-origin` and `access-control-allow-origin: *`, since the frame's origin is not the console's and a module script, as a font, is fetched with CORS; the console's stylesheet for frames carries the same two. The document links `fonts.css` before the stylesheet: the `@font-face` rules of the console's own build, their files served at `GET /api/v1/plugin-runtime/assets/<file>`, fonts alone, so that a frame reads in the console's faces; empty where the server has no build. In a frame document the stylesheet paints no background and lets the document be as tall as its content (`html[data-console]`), so that the box the console draws shows through and the frame shrinks as well as grows. A bundle holds no secret, since anyone may fetch it. The console's own pages gain `frame-src 'self'` in their policy and nothing else.
355
+
356
+ ### The bridge
357
+
358
+ The console's page and the frame speak over `postMessage`, every message `{ v: 1, t, ... }`; the page accepts messages from the frame's `contentWindow` alone, the frame from the origin in `data-console` alone.
359
+
360
+ | From | `t` | Carries |
361
+ |---|---|---|
362
+ | the page | `init` | `plugin: { key, version }`, `page` or `widget`, `project?: { key, name }`, `person: { id, login, name, language, admin }`, `roles`, `path` (what follows the page in the address), `language`, `theme: { [token]: value }` |
363
+ | the page | `result` | `id`, `ok`, `value` or `error: { code, message }` |
364
+ | the page | `event` | `kind: "records" \| "command" \| "plugin"`, `data`: something of this plugin changed. `command` and `records` come from the project's feed, on every `plugin.command` of the plugin on the project the page is on; `plugin` from `GET .../plugin-events`, asked for every few seconds while a frame listens |
365
+ | the page | `theme` | the tokens, when the console's look changes |
366
+ | the page | `path` | `path`, when the console navigated within this page's address |
367
+ | the frame | `ready` | nothing; the page answers with `init` |
368
+ | the frame | `call` | `id`, `method`, `params`: `command` (`name`, `args`), `query` (`name`, `args`), `records.list`, `records.get`, `records.versions`, `files.get` (`id`; the bytes come back transferred), `files.put` (`bytes`, `name`, `type`; a handle comes back) |
369
+ | the frame | `navigate` | `path`: a path of the console's, which the page follows in place; a URL is refused |
370
+ | the frame | `resize` | `height`, which the page gives the frame, up to a bound for a widget |
371
+ | the frame | `title` | `text`, for the document's title |
372
+ | the frame | `download` | `id` of a file, which the page downloads |
373
+ | the frame | `subscribe` | `kinds`, to receive `event` |
374
+
375
+ The page performs every `call` with the person's session under `/api/v1/plugins/<key>/` only; a method or a path outside it is refused before any request, as `plugin.forbidden`. A page thus holds exactly the authority of its plugin's server module, as the person using it.
376
+
377
+ `@skillcdn/console/plugin/web` is the frame's side: `connect()` says `ready`, waits for `init`, and resolves to `{ context, call, command, query, records, files, navigate, download, setTitle, onEvent, onTheme, onPath, close }`, applies the tokens to `:root` and the language to the document, and watches the body's height; it depends on nothing, and a refusal arrives as a `PluginBridgeError` with the console's code. `@skillcdn/console/plugin/web/react` adds `usePlugin()` for a plugin that chooses React. A page renders nothing a person wrote as HTML, as the console's own pages do not.
378
+
379
+ ### Tabs, sub-tabs and addresses
380
+
381
+ - At the workspace, after the console's own tabs and the organization's pages, and before Settings, which is last, come the groups that hold a plugin the person has a role in; in a project the same, before the project's settings. A group with one plugin is a tab with the plugin's name, leading to its first page; a group with several is a tab with the group's name that opens the list of its plugins, each leading to its first page (a `<details>`, as the person's menu is; `NavItem.children`). The groups the console knows have names in every language pack; a group a plugin declares has the plugin's. Groups and plugins are ordered by `menu.order`, then by key.
382
+ - A plugin with several pages at a level has a row of sub-tabs the console draws above the frame, in the manifest's order; a page's `wide` widens the frame as `CustomPage.wide` does.
383
+ - Addresses: `/plugins/<key>` is the first page, `/plugins/<key>/<page>` a page, `/plugins/<key>/<page>/<rest>` a page with `<rest>` handed to the frame as `path`; on a project, `/projects/<pkey>/plugins/<key>[/<page>[/<rest>]]`. The custom pages of a console keep `/pages/<key>` ([ADR-0010](../adr/0010-the-default-uis-addresses-name-what-they-show-and-every-page-and-form-has-one.md)).
384
+ - On a project, the tab row gains the same, for the plugins on that project with `level: project` pages, to the members who hold a role.
385
+ - A person without a role sees no tab, and a plugin's address answers not found, as a project they may not see does.
386
+
387
+ ### Widgets
388
+
389
+ The front page (`/`) shows the widgets the person holds a role for, `home:top` above the projects and `home:bottom` below, in a grid of six columns: `third` takes two, `half` three, `full` six. Each widget is a frame with the same document and bridge, `widget=<key>` in place of `page`, its name above it, its height what it asks up to 480 pixels.
390
+
391
+ ### The look
392
+
393
+ The console's page reads its computed `--sc-*` tokens (the list is `CONSOLE_TOKENS` in the package) and sends them in `init.theme` and in `theme`; the frame's SDK sets them on `:root`, so that a custom console's look reaches its plugins' pages. `GET /api/v1/plugin-runtime/console.css` is the package's `console.css`, so that the `sc-*` classes work in a frame. Tokens added to the console's set for plugins: `--sc-color-info` and `--sc-color-info-subtle`; `--sc-color-neutral` and `--sc-color-neutral-subtle`, a state neither good nor bad; `--sc-series-1` to `--sc-series-6`, a palette for figures; `--sc-numeric`, the font features for tabular numerals. A plugin's own classes begin with `pl-<key>-`; `console-plugin check` warns of others. The frame's `lang` is the person's language, and the console shows the manifest's names in it; the words inside the frame are the plugin's, from `init.language`.
394
+
395
+ ### The settings pages
396
+
397
+ The Settings page lists the plugins with their state, for an administrator, each leading to its own page, and `/settings/plugins` is that list alone; `/settings/plugins/<key>` is one, read at a glance and with as few words as it takes: its name, key, version and state in the head, with the way to disable it; why it cannot run, where it cannot; a row each for its board project, the projects it is on, the capabilities it holds, its commands and queries, and what agents read of it; where it is not enabled, the form that enables it, in three parts: the capabilities, each by the name the pages give it in the person's language with one sentence of what it opens (the packs' `plugins.capability`; the catalogue above is what they say), ticked one by one, those consented to before ticked already; the board project, to make (the form filled from `board.defaults`) or to choose; the projects, all or some; the settings form from the manifest, and each secret with when it was set, never its value; the roles the plugin declares, each with what it is for and who holds it, every member for `defaultRole`, each grant (a person, a project's members, everyone) a chip with the way to revoke it, and one choice to grant the role to someone not holding it yet; the plugin's events; export and purge, the latter offered once the plugin is disabled and asking for the key to be typed. Administrators only; no plugin code runs on these pages.
398
+
399
+ ## Security model
400
+
401
+ The install path is the first line (a release of the deployment, from a lockfile, under review); the host is the second; the capabilities and the roles the third; the record the last. What follows holds for a plugin that is malicious.
402
+
403
+ **The ceiling.** Whatever its manifest asks and whatever was consented to, a plugin cannot:
404
+
405
+ | | Held by |
406
+ |---|---|
407
+ | read a secret, a credential or the environment | the host's empty environment; secrets used by the parent, never sent |
408
+ | reach the database | no connection in the host; `ctx.sql` on the plugin's own role |
409
+ | read a file outside its own directory, or write any file | the permission model |
410
+ | start a process, load an addon, open a socket | the permission model, the module hook, the removed globals |
411
+ | answer a decision, or act as a person it is not | no such call; every call is the caller's or the console's |
412
+ | change a person, a role, a membership, a project's settings or the workspace's | no such call |
413
+ | read another plugin's records or secrets, or write outside its collections, its schema and its folder | the parent's checks on every call |
414
+ | make a request a person could not make | the person's own policy on every read |
415
+ | hold the console: run past its time, grow past its memory, send more than fits | the bounds; the thread ended alone |
416
+ | hide: run a command, change the board or fail without a trace | the events and the log |
417
+
418
+ **The reach, by capability,** is the last column of the catalogue above. In sum: a plugin reaches what its role holders may see through it and what its consented capabilities open; sending anything out needs an `outbound` host the administrator consented to; what it does on a board is marked and reversible.
419
+
420
+ **What is checked, not enforced.** The hardening of a thread is defence in depth: a sandbox of its own would be a host container without egress, left to later. `console-plugin check` scans a plugin for what the host refuses, so that an author learns before an administrator does.
421
+
422
+ **The pages.** A frame's code runs in an opaque origin and reaches the console through the bridge alone, which performs its plugin's own routes and nothing else; it holds the person's authority within its plugin, exactly as the plugin's server does. Nothing a plugin writes is rendered as HTML by the console's pages: a tab, a sub-tab and a widget's name are text.
423
+
424
+ **The record.** Who did what, through which plugin, is on the board: `plugin.command` for every command, the board's own events with the plugin's key for every change, `plugin.enabled` and its kin for every administrative act. The settings page shows a plugin's events, and a feed is filtered by plugin. What was read, queried, received and run on schedule is in the operational log, a line each, with the plugin, the person, the name, the duration and the outcome.
425
+
426
+ ## Limits and refusals
427
+
428
+ | Bound | Value |
429
+ |---|---|
430
+ | a command; a query or an endpoint; a hook; a job; `onEnable`, `onDisable`, an upgrade | 30 s; 10 s; 30 s; 5 min; 10 min |
431
+ | a message between host and parent; a query's answer; an endpoint's body and answer | 4 MiB; 4 MiB; 1 MiB and 4 MiB |
432
+ | a command line sent to `invoke`; one word of it; the files of one command | 200 words; 50,000 characters; 20 |
433
+ | a plugin's memory | `PLUGIN_MEMORY_MB_AT_MOST`, 256 unless set |
434
+ | records in a plugin; a record; versions of a record; the files' bytes in a plugin | 100,000; 64 KiB; 100; `PLUGIN_FILE_BYTES_AT_MOST`, none unless set |
435
+ | a plugin's own events | 100,000 kept, 90 days |
436
+ | outbound requests; an answer; its time | 60 a minute per plugin; 10 MiB; 10 s |
437
+ | webhook requests | 600 a minute per plugin |
438
+ | `ctx.log` | 100 lines a minute |
439
+ | collections; commands and queries; endpoints; pages; widgets; roles; settings; indexes of a collection | 50; 100; 20; 12; 4; 10; 50; 8 |
440
+ | SQL: a statement; a result; connections | 10 s; 10,000 rows and 4 MiB; 4 |
441
+ | a page of a list | 200 |
442
+
443
+ | Status | `code` | When |
444
+ |---|---|---|
445
+ | 404 | `plugin.not_found` | no plugin has the key |
446
+ | 409 | `plugin.disabled` | the plugin is installed and not enabled |
447
+ | 503 | `plugin.unavailable` | the plugin failed to load, its host is away, or this deployment lacks what it needs |
448
+ | 403 | `plugin.forbidden` | the caller lacks the role, the plugin lacks the capability, or the item is another's |
449
+ | 400 | `plugin.invalid_args` | the words or the arguments do not fit the definition; the message names the first at fault |
450
+ | 504 | `plugin.timeout` | the plugin's code ran past its bound |
451
+ | 409 | `plugin.too_many` | a quota of the plugin is reached |
452
+ | 409 | `plugin.conflict` | the record moved on since `baseVersion` |
453
+ | 409 | `plugin.needs_consent` | enabling, or a call, before the capabilities are consented to |
454
+ | 409 | `plugin.requires` | a required plugin is not enabled or not in range |
455
+ | 409 | `plugin.required_by` | another enabled plugin requires this one |
456
+ | 429 | `plugin.rate_limited` | more webhook or outbound requests than the bound |
457
+ | 500 | `plugin.failed` | the plugin's code threw what is not its own refusal, or the console could not perform a call on `ctx`; what was said is in the log, never in the answer |
458
+ | 400 | `plugin.<key>.<code>` | the plugin's own refusal, `code` as the plugin says, the message its own |
459
+
460
+ ## Authoring
461
+
462
+ - `console-plugin init <dir> --key <key> [--name <name>] [--console <dependency>] [--package <name>] [--repository <url> [--directory <path>]] [--languages <tags>]` writes a plugin from the template in the package (`template/plugin`, the words `__package__`, `__key__`, `__name__` and `__console__` replaced in its files and their names): the manifest, a server module with one collection, one command and one query, the source of a web bundle with the bridge client, one page and one widget, one skill with `SKILLCDN.md`, tests against `@skillcdn/console/plugin/testing` and of the package as a console reads it, and the build with esbuild (`build.mjs`: the server module, the web bundle and its stylesheet). `examples/plugin-hello` in the repository is that result, built, and the check holds the two equal.
463
+ - `init` takes beside the key what a plugin bound for a repository or a registry would otherwise have adapted by hand: `--package <name>`, the package's name (`console-plugin-<key>` unless said; a scoped name, `@<scope>/console-plugin-<key>`, gets `publishConfig.access: "public"`); `--repository <url>` with `--directory <path>` in a repository of several, which fill `package.json`'s `repository`, `homepage` and `bugs` and the manifest's `store.homepage` and `store.repository`; and `--languages en,ko`, the languages of the plugin's words, `en` first, which gives every language map of the manifest and the `translations` of `SKILLCDN.md` and of the skill the other languages as the English words, for the author to translate. `check` warns of a translation left the same as English.
464
+ - `console-plugin check [<dir>]` reads the package as the console would and fails on: an invalid manifest; a reserved or malformed key; a `pluginApi` the command does not know; a server module that does not load under the host's hardening, or yields a definition naming a role, a setting, a collection or a page that is not declared; a built-in the host refuses, imported anywhere in the server bundle; a web bundle that is not self-contained (a script, a style or an import from elsewhere; `fetch`, `XMLHttpRequest`, `WebSocket` or `document.cookie` in it); a class without the `pl-<key>-` prefix; skills that `skillcdn check` refuses; a license that is not permissive; a `files` field that leaves out what runs. It is what a store would ask of a plugin. It needs Node.js 24 or newer; it warns, and does not fail, on a private package, a class without the prefix, a capability no definition uses, and skills left out; it exits `1` with every problem, `0` with a summary.
465
+ - `createTestContext({ collections?, plugin?, caller?, console?, sql?, fetch?, secrets?, plugins?, now?, language? })` from `@skillcdn/console/plugin/testing` is a `ctx` for a plugin's own tests: records in memory, checked against the schema and the indexes as the console checks them, versions, links and files kept; the console answering what the test provided and refusing the rest as `plugin.forbidden`; `emitted` and `logged` to assert on; `all(collection)` for every record; `as(caller)` for the same store as someone else.
466
+ - The skill `skills/building-a-plugin` is the map: what to ask, how to split a feature into commands, queries, decisions, tasks and jobs so that agents work and people decide, records or SQL, running a local console with the plugin linked into `PLUGINS_DIR`, the tests, `check`, publishing or tagging, and handing the install to the deployment as a task. It names this document for every field and bound, and repeats none.
467
+ - Before 1.0 the plugin API moves with the console's line; `pluginApi` is raised when the contract breaks, and the console names the majors it serves; a plugin is rebuilt against the console it runs on, with no floor between them. The generic routes and the command are in the REST snapshot, so an addition stays an addition.
468
+
469
+ ## Environment
470
+
471
+ | Variable | Default | Meaning |
472
+ |---|---|---|
473
+ | `PLUGINS_DIR` | unset | the directory of the installed plugins; unset, the console has no plugins, spawns no host and answers `GET /api/v1/plugins` with none |
474
+ | `PLUGIN_FILE_BYTES_AT_MOST` | unset | the bytes a plugin's files may hold together; unset, no bound |
475
+ | `PLUGIN_MEMORY_MB_AT_MOST` | 256 | the memory a plugin's thread may hold |
476
+
477
+ Plugin pages need `PUBLIC_URL`, as signing in does. A deployment installs its plugins by building them into its image from a list and a lockfile, with no install scripts, and names the directory ([deploy/README.md](../../deploy/README.md), "Plugins in the image", which says the rest: the list, the git installs, `CREATEROLE` for `storage:sql`, the bounds):
478
+
479
+ ```dockerfile
480
+ FROM node:24-bookworm-slim AS plugins
481
+ WORKDIR /plugins
482
+ COPY plugins/package.json plugins/package-lock.json ./
483
+ RUN npm ci --omit=dev --ignore-scripts
484
+
485
+ FROM <the console image>
486
+ COPY --from=plugins --chown=node:node /plugins/ /app/plugins/
487
+ ENV PLUGINS_DIR=/app/plugins
488
+ ```