Documentation
Plugins
Extension manifests, worker-host APIs, assistant tools, and the separate browser plugin installation contract.
Choose the extension surface
@cadcamfun/plugin-sdk defines the worker plugin contract with a plugin.json manifest and API version 1. Its host exposes capability-controlled services and runs plugin functions in Bun workers. The production assistant loader currently accepts plugins that request only ai:tool.
The /api/plugins HTTP family uses a different browser-plugin contract: ZIP packages with manifest.json, a main entry, browser permissions, configuration, and JavaScript bootstrap. Installing a package through that API does not make it a worker SDK plugin. Use the manifest and lifecycle matching the intended integration.
Worker SDK manifest
{
"id": "com.example.shop-helper",
"name": "Shop helper",
"version": "1.0.0",
"apiVersion": "1",
"entry": "index.js",
"capabilities": ["ai:tool"],
"contributes": {
"aiTools": [{
"id": "lookup",
"description": "Look up the shop's documented inspection checklist.",
"kind": "read",
"parameters": { "type": "object", "properties": {} },
"entry": "lookup"
}]
}
} Identifiers use a reverse-DNS form, the version is semantic major.minor.patch with an optional prerelease, and apiVersion must be "1". Entry paths are relative to the plugin directory. Contributions can declare commands, panels, post-processors, operations, and assistant tools; declaring a contribution does not wire that feature into every host.
The schema allows optional signature and dev fields. The current worker host records these values but does not perform cryptographic signature verification during install. A signature field alone is not evidence of a verified publisher.
Module entry and AI functions
definePlugin is a typed identity helper for a module with a manifest and activate(ctx). Activation may return a cleanup function, and the module may expose deactivate. The SDK describes these lifecycle hooks; their availability depends on the concrete worker bootstrap, as detailed below.
Assistant functions are exported through an aiTools record; the contribution's entry names the function in that record. A tool contribution includes its description, JSON parameter schema, and read or write kind. A write tool is routed through a human approval request in the embedded assistant; an outside bridge receives only permitted read plugin tools.
Bundle the worker entry as a self-contained module. The worker host reads the entry source and imports it through a blob module. Treat relative package or file imports as a separate integration concern rather than assuming a Node-style filesystem loader.
Capability-controlled APIs
The SDK recognizes drawing:read, drawing:write, toolpath:read, storage, ui:panel, and ai:tool. Drawing write implies drawing read. The general host derives effective capabilities from the installed manifest and checks them on RPC; the embedding application decides which manifests and surfaces it is willing to load.
The SDK context can expose drawing reads and Add/Update/RemoveEntity patches, toolpath/program reads, private string key-value storage, and declarative command/panel registration. Missing services are absent from the context. Storage keys use plugin:<id>: namespaces and reject absolute or parent-traversal keys.
The production assistant loader supports only ai:tool. A manifest asking for drawing, toolpath, storage, or UI capabilities is skipped with a warning. It does not silently grant those surfaces or load their broader capabilities into the assistant host.
Loading and worker lifecycle
To enable an assistant extension, place its plugin.json and bundled entry under <dataDir>/plugins/<id>/ and list the identifier in <dataDir>/plugins/enabled.json, a JSON array. The folder identifier must match the manifest. Loading occurs while the assistant plugin service is built; a failed plugin logs a warning without stopping the application.
The general host supports installed, enabled, disabled, and faulted states, with install, enable, disable, uninstall, command call, AI-tool call, and configuration operations. Enable creates a worker and completes its bootstrap handshake; disable tears it down. Uninstall removes the registry state and storage namespace through the host's injected dependencies.
Workers start with an empty environment and the bootstrap removes selected global APIs. This is an application-level restriction, not an operating-system sandbox: Bun globals remain reachable. Enable only extension code that is appropriate to run inside the application process boundary.
Runtime limits
The host defines a 256 MB worker memory setting, a 2,000-call-per-minute rate limit, and at most 64 pending calls per plugin. Command timeout behavior is supplied by the embedding host; the assistant loader uses 10 seconds. AI tool calls have a separate 30-second deadline. Timeouts, absent tools, worker crashes, capability failures, and queue exhaustion return typed host failures or readable tool results.
Crash tracking faults a plugin after three crashes within ten minutes. These host controls bound cooperative RPC and lifecycle behavior; the memory setting passed to Bun is not a substitute for a separately isolated executable.
The limits are defined in apps/sidecar/src/plugins/host.ts: CALL_TIMEOUT_MS (5,000 ms per ordinary call), AI_TOOL_TIMEOUT_MS (30,000 ms per assistant tool call), MEMORY_LIMIT_MB (256), CALL_RATE_LIMIT (2,000 calls per plugin per minute), MAX_PENDING_CALLS (64), and CRASH_LIMIT (3 crashes within a 10-minute CRASH_WINDOW_MS, after which the plugin is disabled automatically).
ZIP installation and browser plugin API
POST /api/plugins/install accepts a multipart archive. The installer reads manifest.json from the ZIP root or a single top-level directory, validates the browser-plugin manifest, and stores its files under <dataDir>/storage/plugins/<id>/. Browser manifests use main, engines.cadcam, and a browser permission list, rather than SDK entry, apiVersion, and capabilities.
GET /api/plugins lists those installed entries; detail, enable/disable, uninstall, update, and configuration endpoints operate on their registry. /api/plugins/bootstrap.js?id=… serves JavaScript; /api/plugins/serve?id=…&file=… serves plugin files. These are text/binary operations with their declared content types.
The local registry browser reads package metadata under <dataDir>/plugins-registry. It is a local manifest catalogue. Archive installation, registry listing, and worker assistant extension loading are separate operations.
SDK helpers
effectiveCapabilities, hasCapability, and missingCapabilities support capability inspection. requireCapability raises a capability error when a requested context surface is absent. storageNamespace and storageKey build namespaced keys; parseManifest validates JSON; manifestKey formats an ID/version pair.
Implementation entry points
packages/plugin-sdk/src owns the public manifest, capability, context, plugin, and test-host APIs. apps/sidecar/src/plugins/host.ts owns worker lifecycle and RPC; services/ai-plugins.ts owns consent-file loading and assistant registration. Browser installation is implemented in http/plugins.ts and repos/plugin-registry-repo.ts.
Extension review
Keep contribution schemas accurate, return small structured results, and declare read/write behavior honestly. Test against the actual embedding host: SDK availability does not establish production access to drawing, storage, UI, or post registries. The standalone nikcli plugin is an HTTP action client and has a different purpose from code loaded into a worker.
Complete the sample assistant module
The plugin.json example above names aiTools.lookup. This is the corresponding self-contained index.js. Top-level module code runs when imported; the declared lookup function runs when called.
export const aiTools = {
lookup: async () => ({
ok: true,
checklist: [
"Compare the drawing revision with the job traveller.",
"Record the measuring instrument and its calibration status.",
"Record each inspected dimension and the measured value."
]
})
} These are sample shop instructions, not a built-in inspection standard. Replace them with the content your extension actually owns. To admit this module at the next service initialization, the enabled file is:
["com.example.shop-helper"] The running catalogue exposes an identifier derived from plugin and tool IDs. Discover it through GET /api/ai/actions; do not assume the browser ZIP registry is the assistant's enabled list. Reloading the application/service is the reliable way to reread the production consent file; no filesystem watcher is configured by this loader.
Source: apps/sidecar/src/services/ai-plugins.ts, packages/ai/src/tools/plugin-tool.ts, and apps/sidecar/test/ai-plugin-tools.test.ts.
Argument schemas and lifecycle limits
A tool's local ID must start with a lowercase letter and contain lowercase letters, digits, or underscores, with a 40-character maximum. Descriptions contain 10–2,000 characters. The contribution must name a non-empty entry function and a JSON parameter schema. The application adapter validates arguments before calling the worker; a refusal can return a structured ok: false result without terminating the plugin.
The public SDK describes activate, cleanup, and deactivate entry points. The current Bun worker bootstrap's activate/deactivate RPC handlers acknowledge lifecycle messages but do not invoke those exported module functions. Assistant extensions should therefore put their callable behavior in aiTools and avoid depending on SDK activation callbacks for setup or cleanup.
The general worker bootstrap also contains placeholder drawing/toolpath/UI handlers. They do not constitute production drawing or UI integration. The assistant loader's restriction to ai:tool prevents these unsupported surfaces from being advertised as working application access.
Source: packages/plugin-sdk/src/manifest.ts, plugin.ts, apps/sidecar/src/plugins/host.ts (PLUGIN_BOOTSTRAP), and services/ai-plugins.ts.
Diagnose a missing or failing extension
- No tools in the catalogue: check valid enabled JSON, exact folder/manifest ID match, manifest decoding, and the
ai:toolcapability. Read the startup warning for a skipped plugin. - Manifest requests unsupported surfaces: remove unsupported capabilities only if the code truly does not need them. An assistant plugin cannot acquire drawing-write access merely by declaring it.
- NoSuchTool: make
contributes.aiTools[].entrymatch an exported function underaiTools. The contribution ID and entry name serve different purposes. - Argument validation failure: inspect the JSON schema and required fields. Wrong values are refused before execution, as demonstrated by the real-worker and application integration tests.
- Timeout or faulted state: investigate blocking work, unresolved promises, or crashes; repeated crashes fault the plugin. Re-enable only after correcting the cause.
- ZIP installer rejects the manifest: use the browser-plugin
manifest.jsoncontract for that endpoint. Renaming a workerplugin.jsondoes not translate its fields.
Source: apps/sidecar/test/plugins-ai-tools.test.ts, ai-plugin-tools.test.ts, plugins-http.test.ts, and src/plugins/host.ts.
Manifest reference
Every plugin ships a plugin.json. parseManifest(json) parses and validates it in one step, turning a JSON syntax error into the same error channel as a schema error, so an install screen only needs to handle one failure type.
| Field | Rule |
|---|---|
id | Reverse-DNS identifier: 3–128 characters, lowercase letters, digits, and hyphens in at least two dot-separated labels, for example com.example.gear-generator. |
name | Non-empty display name. |
version | major.minor.patch, optionally followed by a pre-release suffix such as -beta.1. |
apiVersion | Must be the string "1"; it is the only plugin API version. |
entry | ES module path relative to the plugin folder. |
capabilities | Array of capability strings (see below). Anything outside the closed list is a manifest error. |
contributes | Optional commands, panels, postProcessors, operations, and aiTools. |
signature | Ed25519 signature over the archive hash; required for packaged installs. |
dev | true marks a local development plugin: signature checks are skipped and the UI shows a badge. |
| Contribution | Fields |
|---|---|
commands[] | id, title, optional keybinding such as Mod+Shift+G. |
panels[] | id, title, entry: an exported function that returns the panel's declarative tree. There is never DOM access. |
postProcessors[] | id, title, entry: an exported (program, options) => string function. |
operations[] | id, title, entry: an exported record with a parameter schema and a pure generate(params, geometry, tool, stock) that returns a toolpath. |
aiTools[] | id matching ^[a-z][a-z0-9_]{0,39}$, description (10–2,000 characters; the model reads it), kind read or write, parameters (JSON Schema of the arguments), and entry. |
Helpers: manifestKey(m) returns id@version; requestedCapabilities(m) returns the sorted capability list for display; isDevPlugin(m) and isSigned(m) answer the install-policy questions.
Capabilities and the plugin context
Capabilities are the only thing a plugin can act through. The manifest declares what the plugin needs, the user grants a subset, and the host builds a context that contains exactly the granted surfaces. Each surface is optional in the type, so a missing capability is visible both at compile time and at runtime instead of becoming a silent no-op.
| Capability | Context surface | Allows | Implies |
|---|---|---|---|
drawing:read | ctx.drawing.read() | Read the open drawing (or undefined when none is open). | — |
drawing:write | ctx.drawing.patch(patch) | Apply AddEntity, UpdateEntity, or RemoveEntity patches; returns the new revision. | drawing:read |
toolpath:read | ctx.toolpath.list(), .program(id) | Read toolpaths and G-code programs. | — |
storage | ctx.storage | Namespaced key-value strings: get, set, delete, keys. | — |
ui:panel | ctx.ui | registerCommand and registerPanel (declarative only). | — |
ai:tool | none (manifest contributions) | Offer tools to the assistant and, read-only, to a standalone nikcli. | — |
effectiveCapabilities(granted) resolves implications, so a plugin that declares drawing:write does not need to declare drawing:read. Every context also has pluginId, the resolved capabilities, and a log with debug, info, warn, and error; the host prefixes log lines with the plugin ID. Use requireCapability(ctx, capability, surface) to obtain a surface or fail with a CapabilityError that names the missing grant.
Test a plugin with the SDK test host
createTestHost builds an in-memory host with the same capability rules as the sidecar. Only granted capabilities appear on the context, and you can supply a drawing, toolpaths, and programs. The example below is complete and runs with bun in the workspace.
import { cad } from "@cadcamfun/core"
import { createTestHost, definePlugin, requireCapability, testManifest } from "@cadcamfun/plugin-sdk"
const manifest = testManifest({
id: "com.example.frame",
capabilities: ["drawing:write", "storage", "ui:panel"],
})
const plugin = definePlugin({
manifest,
activate(ctx) {
const drawing = requireCapability(ctx, "drawing:write", "drawing")
ctx.ui?.registerCommand({ id: "frame.add", title: "Add frame line" })
ctx.storage?.set("runs", "1")
drawing.patch({
_tag: "AddEntity",
entity: { ...cad.baseEntity("frame-1"), _tag: "Line",
start: { x: 0, y: 0 }, end: { x: 100, y: 0 } },
}) // returns revision 1
return () => ctx.log.info("teardown") // runs on deactivation
},
})
const host = createTestHost({ manifest, drawing: cad.emptyDrawing("d1", "Demo") })
const stop = await host.activate(plugin)
host.drawing()?.entities.length // 1
host.context.storage?.get("runs") // "1"
host.ui.commands // [{ id: "frame.add", title: "Add frame line" }]
await stop()
// Without the grant, requireCapability throws:
const denied = createTestHost({ manifest, granted: ["storage"] })
await denied.activate(plugin)
// CapabilityError: Plugin com.example.frame may not use "drawing:write":
// it was not granted at install time testManifest(overrides)starts from a minimal valid manifest (com.example.test-plugin, version 1.0.0, API 1, entryindex.js, no capabilities).granteddefaults to everything the manifest asks for. Pass a smaller list to test the denied paths.host.storageis a snapshot taken when the host is created. Read live values throughhost.context.storage.host.on(commandId, handler)andhost.invoke(commandId, args)exercise registered commands.runPostProcessorruns a contributed post against a program, andkeyOf(pluginId, key)shows the namespaced storage key so tests can assert isolation.
How the application loads assistant plugins
- At startup the sidecar reads
<data dir>/plugins/enabled.json, a JSON array of plugin IDs. Nothing is loaded unless it is listed there. - For each ID it reads
<data dir>/plugins/<id>/plugin.jsonand parses it. The folder name must equal the manifestid. - Only
ai:toolis wired in this host. A manifest that asks for drawing, toolpath, storage, or UI access is skipped with a warning rather than loaded with surfaces that would silently do nothing. - The plugin is installed into a worker host and enabled. A plugin that fails to load logs
Plugin <id> was not loaded: ...and never stops the application from starting. - Each contributed tool is offered to the assistant as
plugin_<slug of plugin id>_<tool id>, at most 80 characters, where the slug lowercases the ID and replaces other characters with underscores.com.example.bolt-circlewith toolbolt_circlebecomesplugin_com_example_bolt_circle_bolt_circle. - Arguments are validated against the tool's JSON Schema before the worker sees them. If the schema exceeds 20,000 characters, is not an object schema, or cannot be converted, the host accepts any object and the plugin must validate.
- A call that throws, times out, or finds the plugin gone returns
{ ok: false, error }(error text up to 500 characters) to the assistant instead of crashing the turn. Awritetool is confirmed with the person before every call, whatever its arguments say.
Not an operating-system sandbox
The worker has no process, fetch, or file handle of its own, but Bun globals remain reachable inside it. Enable only plugins whose code you trust, and keep write tools to a minimum.
Identifier and version rules, tested
| Value | Valid? | Rule |
|---|---|---|
com.example.gear-generator | yes | Reverse-DNS: lower-case segments of letters, digits and -, at least two, 3–128 characters |
a.b | yes | Two one-letter segments are enough |
gear | no | Needs at least one dot |
Com.Example.x | no | Upper case is not allowed |
com.example_x.y | no | Underscore is not allowed |
| Version | Valid? |
|---|---|
1.0.0 | yes |
1.2.3-beta.1 | yes |
1.0 | no: three numbers required |
v1.0.0 | no: no prefix |
apiVersion must be the string "1", the only plugin API major that exists. AI tool ids must match ^[a-z][a-z0-9_]{0,39}$ and are shown to the assistant as plugin_<plugin>_<id>; descriptions must be 10–2,000 characters because the model reads them.
Contribution types
| Key | Fields | Purpose |
|---|---|---|
commands | id, title, optional keybinding (e.g. Mod+Shift+G) | Commands in the palette |
panels | id, title, entry | A panel rendered from a declarative tree the named function returns; no DOM access |
postProcessors | id, title, entry | A (program, options) => string post registered at runtime |
operations | id, title, entry | An exported { schema, generate } record |
aiTools | id, description, kind read|write, parameters (JSON Schema), entry | Tools offered to the assistant; a write tool is confirmed before every call |
Other manifest fields: signature (Ed25519 over the archive hash, required for packaged installs) and dev (local development plugin: skips signature checks and shows a badge).
Capability resolution, tested
import { effectiveCapabilities, missingCapabilities, storageKey, requireCapability } from "@cadcamfun/plugin-sdk"
effectiveCapabilities(["drawing:write", "storage"])
// ["drawing:read", "drawing:write", "storage"] (write implies read; sorted)
missingCapabilities(["drawing:read", "toolpath:read", "ai:tool"], ["drawing:write"])
// ["toolpath:read", "ai:tool"]
storageKey("com.example.gear", "presets/m2") // "plugin:com.example.gear:presets/m2"
storageKey("com.example.gear", "../x")
// RangeError: storage key "../x" may not escape the plugin namespace (same for "/abs")
requireCapability(ctxWithoutDrawing, "drawing:read", "drawing")
// CapabilityError (_tag "CapabilityDenied"):
// Plugin com.example.gear may not use "drawing:read": it was not granted at install time | Surface on the context | Present when granted | Methods |
|---|---|---|
drawing | drawing:read or drawing:write | read() → drawing or undefined; patch(p) → new revision, with AddEntity, UpdateEntity, RemoveEntity patches |
toolpath | toolpath:read | list(), program(toolpathId) |
storage | storage | get, set, delete, keys; string values |
ui | ui:panel | registerCommand, registerPanel |
log | always | debug, info, warn, error |