Skip to content
CADCAMFUN
Docs

Documentation

Write an assistant tool plugin

Build a self-contained read tool, validate its manifest and arguments, enable it deliberately, and test the worker boundary you actually receive.

Choose the assistant extension format

The production assistant extension loader accepts a directory containing plugin.json and a bundled JavaScript entry. It imports the module into a Bun worker and exposes declared functions from its aiTools record to the assistant. The currently supported capability is ai:tool. This guide builds a read-only bolt-circle calculator that returns coordinates; it does not insert geometry into a drawing.

There is also a browser-plugin ZIP API under /api/plugins with manifest.json, main, browser permissions, and bootstrap assets. That installer does not enable the worker plugin described here. The plugin HTTP reference documents that separate format; Plugins explains both surfaces. Do not translate between them by merely renaming a manifest.

An assistant tool is code executed inside the application runtime boundary. Host capability checks limit access through host RPC, but the current worker is not an operating-system sandbox. Use extension code whose execution is appropriate for that environment, and make its declared read/write behavior agree with what the function actually does.

Create the directory and consent record

Use the exact application data directory of the sidecar you intend to extend. Create this structure, keeping the directory ID identical to the manifest's reverse-DNS ID:

Application plugin directory
<dataDir>/plugins/
  enabled.json
  com.example.bolt-circle/
    plugin.json
    index.js

The production loader reads enabled.json as a JSON array of safe identifiers. It ignores duplicate/invalid entries, and malformed or missing JSON means no plugins are enabled. An unlisted folder is not loaded. The loader also rejects a folder/manifest ID mismatch and manifests that request unsupported capabilities.

Loading occurs when the assistant plugin service initializes. It does not configure a filesystem watcher for hot reload. Prepare and validate files first, then enable/restart that instance. A failed plugin logs a warning while other plugins and application startup continue; inspect those warnings instead of assuming an enabled-file entry guarantees a running tool.

Declare a precise tool contract

This complete manifest names a single read tool. The contribution ID is the model-facing local identifier; entry names the function exported by the module. Those strings may differ, as bolt_circle and boltCircle do here.

plugin.json
{
  "id": "com.example.bolt-circle",
  "name": "Bolt circle calculator",
  "version": "1.0.0",
  "apiVersion": "1",
  "entry": "index.js",
  "capabilities": ["ai:tool"],
  "contributes": {
    "aiTools": [{
      "id": "bolt_circle",
      "description": "Calculate equally spaced XY hole centers around the origin for a bolt circle. Returns coordinates without changing a drawing.",
      "kind": "read",
      "entry": "boltCircle",
      "parameters": {
        "type": "object",
        "properties": {
          "count": { "type": "integer", "minimum": 1, "maximum": 64 },
          "radius": { "type": "number", "exclusiveMinimum": 0 }
        },
        "required": ["count", "radius"],
        "additionalProperties": false
      }
    }]
  }
}

The SDK requires reverse-DNS IDs of 3–128 characters, a semantic major.minor.patch version with optional prerelease, API version "1", non-empty name/entry, and recognized capabilities. Tool IDs start with a lowercase letter and use lowercase letters, digits, or underscores up to 40 characters. Descriptions contain 10–2,000 characters. Tool kind is read or write; there is no destructive extension kind.

Keep the parameter schema small and descriptive. Its role is to help the model produce valid arguments and let the adapter reject ordinary mistakes before execution. It does not replace validation inside code.

Implement the self-contained module

The entry exports aiTools with the entry function named in the manifest. It validates runtime arguments again and returns a small JSON-compatible result. Finite coordinate arithmetic and bounded count keep malformed input from producing an unbounded list or meaningless output.

index.js
export const aiTools = {
  boltCircle: async (args) => {
    if (!args || typeof args !== "object") {
      return { ok: false, error: "Expected count and radius" }
    }
    const { count, radius } = args
    if (!Number.isInteger(count) || count < 1 || count > 64) {
      return { ok: false, error: "count must be an integer from 1 to 64" }
    }
    if (typeof radius !== "number" || !Number.isFinite(radius) || radius <= 0) {
      return { ok: false, error: "radius must be a positive finite number" }
    }
    const holes = Array.from({ length: count }, (_, index) => {
      const angle = 2 * Math.PI * index / count
      return {
        x: Number((radius * Math.cos(angle)).toFixed(4)),
        y: Number((radius * Math.sin(angle)).toFixed(4))
      }
    })
    return { ok: true, units: "mm", holes }
  }
}

Count 4 and radius 10 return centers at approximately (10,0), (0,10), (−10,0), and (0,−10), in counter-clockwise order around the origin. The result deliberately contains no database mutation or drawing identifier. A client wanting to insert holes must use a separate supported application action with its own access and permission behavior.

Bundle dependencies into the entry rather than assuming Node-style relative imports resolve. The host reads the entry text and imports it through a blob module. Module top-level code runs on import, so avoid side effects merely from loading a calculator.

Validate the manifest and both argument paths

Validate before adding the plugin to enabled.json. This development check uses the same SDK manifest parser and AI schema adapter as the application. Place the script in a workspace with those dependencies, such as apps/sidecar, and pass the absolute plugin directory as its argument.

Development validation script
import { join } from "node:path"
import { Effect } from "effect"
import { parseManifest } from "@cadcamfun/plugin-sdk"
import { pluginParameters } from "@cadcamfun/ai"

const directory = process.argv[2]
if (!directory) throw new Error("Pass the absolute plugin directory")
const manifest = await Effect.runPromise(parseManifest(
  await Bun.file(join(directory, "plugin.json")).text()))
if (manifest.capabilities.some((capability) => capability !== "ai:tool")) {
  throw new Error("This production loader accepts only ai:tool")
}
const contribution = manifest.contributes?.aiTools?.[0]
if (!contribution) throw new Error("No tool contribution")
const parameters = pluginParameters(contribution.parameters)
console.log(parameters.safeParse({ count: 4, radius: 10 }).success) // true
console.log(parameters.safeParse({ count: 0, radius: 10 }).success) // false
const module = await import(join(directory, manifest.entry))
console.log(await module.aiTools[contribution.entry]({ count: 4, radius: 10 }))
console.log(await module.aiTools[contribution.entry]({ count: 0, radius: 10 }))

The last two calls test function-level behavior independently of adapter rejection. The SDK validates the manifest structure, but the production loader adds its own capability and folder-ID rules. Test all three boundaries: declaration, argument adapter, and actual module function.

Enable and discover the actual tool identifier

After validation, enable the ID in the application data directory:

enabled.json
["com.example.bolt-circle"]

Restart/reinitialize the intended sidecar service and inspect logs for load warnings. Then list GET /api/ai/actions. Read plugin tools are exposed there as well as to the embedded assistant; policy can narrow the catalogue. The tool identifier is generated from plugin ID and local tool ID, normalized and bounded to the host's identifier size.

For this manifest, the current derived ID is plugin_com_example_bolt_circle_bolt_circle. Discover it rather than reimplementing the naming rule in every client. A long plugin ID may be truncated to leave room for the tool suffix. The catalogue's description includes the plugin name so the user can identify the source.

A write tool is marked for human approval in the embedded assistant, regardless of ordinary permission mode. The outside bridge exposes only read plugin tools and refuses write extension calls even with a read/write token. Keep kind accurate; the application cannot infer arbitrary module side effects from a JSON schema.

Call a read tool through scoped outside access

Use a bridge token issued by the application for a real project. Its bearer opens only GET /api/ai/actions and POST /api/ai/actions/invoke. It does not permit callers to install plugins, edit credentials, or enumerate unrelated projects. The token's project/mode is checked by the action handler; an application-wide token is unnecessary for a standalone calculator client.

bridge-call.json, with a real project ID
{
  "projectId": "replace-with-your-project-id",
  "tool": "plugin_com_example_bolt_circle_bolt_circle",
  "input": { "count": 4, "radius": 10 }
}
Invoke the running plugin tool
curl --fail-with-body \
  -H "Authorization: Bearer $CADCAMFUN_BRIDGE_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @bridge-call.json \
  "$CADCAMFUN_URL/api/ai/actions/invoke"

Plugin inputs are the declared argument object; unlike built-in grouped tools, this read plugin does not require an action discriminator inside input. The bridge wraps the returned value with tool/action metadata; the action is run. Function-level ok=false remains a readable result rather than an HTTP transport success proving a useful coordinate list.

Bridge policy checks tool.use and bridge.use. A missing, expired, revoked, or wrong-kind token yields authorization failure. A token bound to another project is not a way to widen this call's scope.

Understand schema conversion and capability limits

The adapter converts the contribution's JSON schema to zod when representable. If conversion fails, the schema exceeds 20,000 serialized characters, or it does not describe an object, it falls back to an arbitrary-object schema. This is why the example function checks count and radius itself. A supported schema's negative fixture passing locally does not prove every schema feature will remain representable.

The SDK recognizes drawing:read, drawing:write, toolpath:read, storage, ui:panel, and ai:tool; drawing write implies drawing read. The production assistant loader currently accepts only ai:tool. Adding drawing:write to this manifest causes it to be skipped, not granted a drawing editor context.

The general worker bootstrap contains placeholder drawing/toolpath/UI handlers. Those handlers are not a production geometry integration. Likewise, declaring post-processor or operation contributions does not make the assistant loader wire them into manufacturing registries. Choose a supported application action when the feature needs to change real CAD/CAM data.

Design around the implemented worker lifecycle

The SDK describes a module with manifest, activate, optional cleanup, and deactivate hooks. The current production AI path imports the entry and calls exported aiTools functions. Its worker activate/deactivate RPC handlers acknowledge lifecycle messages but do not invoke exported module hooks. A calculator can therefore use the minimal module shown here without depending on activation callbacks.

The host starts one worker per enabled plugin, correlates replies with RPC IDs, and tracks pending calls. A function exception fails that call; focused real-worker tests show that later calls can still succeed. Return structured refusals for expected invalid input, and reserve thrown failures for unexpected computation errors.

A call timeout terminates its worker and faults the plugin immediately. Worker crashes and posting failures also remove the running instance and mark it faulted. Crash history has a ten-minute window, but the current handler faults on a first crash; it does not wait for three successful restart attempts. Correct the source and deliberately reload/re-enable instead of assuming automatic restart.

Keep runtime guarantees in proportion

The production loader supplies a 10-second host budget for ordinary lifecycle/command RPC. AI tool calls have a separate 30-second deadline. The host limits a plugin to 2,000 calls per minute and 64 pending calls. It passes a 256 MB resource-limit setting to Bun when creating the worker. These are runtime controls, not a separate process or operating-system containment policy.

The bootstrap starts with an empty environment and attempts to delete selected process/network globals. Deletion is best-effort and some Bun globals remain reachable. Capability checking is therefore meaningful for the APIs the host supplies, but cannot be presented as complete isolation of arbitrary JavaScript.

Optional signature and dev fields exist in the manifest schema. The current worker installation path records signature text without cryptographic verification. Do not label a publisher verified because that field is present. A self-contained source review and installation policy remain separate decisions from schema acceptance.

Plugins that crash three times within ten minutes are disabled automatically, and an assistant tool call is stopped after 30 seconds. Both values are constants in apps/sidecar/src/plugins/host.ts.

Update, remove, and diagnose a tool

For an update, edit and validate the bundled module/manifest before restarting the target service. Keep its ID stable when it is the same extension and increase the version to identify what was loaded. To stop loading it on future initialization, remove its ID from enabled.json and restart/reinitialize. Changing the file alone does not dispose an already running worker.

If the tool is missing, check the enabled array, exact folder/manifest ID equality, supported capability list, and manifest decode error in logs. NoSuchTool usually means entry does not name an exported aiTools function. An argument refusal points to the schema or function guard. A timeout points to blocking work or an unresolved promise. The browser ZIP enable/config endpoints do not manage this worker consent file.

Focused coverage is in apps/sidecar/test/plugins-ai-tools.test.ts for actual workers and ai-plugin-tools.test.ts for production loading, argument validation, read/write exposure, and bridge behavior. Implementation sources are packages/plugin-sdk/src/manifest.ts, packages/ai/src/tools/plugin-tool.ts, apps/sidecar/src/plugins/host.ts, and services/ai-plugins.ts. Use these boundaries to determine what a successful test actually proved.

Contract rules at a glance

ItemConstraintWhat happens if violated
Tool id^[a-z][a-z0-9_]{0,39}$Manifest decode fails; the plugin is not loaded.
description10–2,000 charactersManifest decode fails.
kindread or write; there is no destructive kindManifest decode fails.
parametersJSON Schema object, at most 20,000 characters when serializedOversized or unconvertible schemas fall back to accepting any object.
CapabilitiesOnly ai:tool in the assistant hostThe plugin is skipped with a warning.
Folder nameEqual to the manifest idThe plugin is skipped with a warning naming both values.
Ordinary call budget5 s per call by defaultThe worker is stopped like any unresponsive plugin.
Assistant tool call budget30 sThe call returns a failure result.
Memory256 MB heapThe worker is killed.
Rate2,000 calls per plugin per minute, 64 pending callsCalls over the rate are refused; a full queue fails with plugin RPC queue is full.
Crashes and timeoutsCounted over 10 minutes, limit 3Each crash or timeout terminates the worker and marks the plugin faulted.

Write the description for the model. State what the tool computes, its units, and when to use it; for example, "Returns hole centres of a bolt circle in millimetres. Use when the user asks for evenly spaced holes on a diameter." Vague descriptions lead to tools being called at the wrong time or not at all.

Pre-release checklist

  1. Bundle the entry into a single self-contained module. The worker cannot resolve workspace imports at runtime.
  2. Run parseManifest on the final plugin.json and fix every reported issue.
  3. Call each exported tool function directly with valid, boundary, and invalid arguments, and confirm that invalid input produces a readable error result rather than an exception that escapes.
  4. Keep results small and JSON-compatible. The assistant reads them, and large outputs crowd the context.
  5. Choose read unless the tool changes something. A write tool prompts the user before every call.
  6. Place the folder under <data dir>/plugins/<id>/, add the ID to enabled.json, restart the sidecar, and confirm the derived tool ID from the assistant's tool listing.
  7. Bump version for every change you ship, and keep id stable so the derived tool ID does not change.

Troubleshooting matrix

SymptomLikely causeFix
Tool never appearsID missing from enabled.json, or the JSON is malformedFix the array; malformed or missing JSON enables nothing.
Log says the folder is named X but the manifest says YFolder and manifest id differRename one so they match exactly.
Log says it asks for drawing:read, ...Unsupported capability in this hostDeclare only ai:tool.
Assistant passes unexpected argument shapesSchema fell back to any objectUse an object schema with properties under 20,000 characters, and validate inside the function too.
Calls return ok: false after about 30 sTool exceeded its time budgetDo less work per call or precompute; avoid unbounded loops.
Plugin stops answering after an errorA worker crash or timeout terminates the worker and marks the plugin faulted; crashes are also counted over a 10-minute window with a limit of 3Fix the crash, then restart the sidecar to load it again.

Lint a manifest before installing

check-manifest.ts
import { decodeManifest, requestedCapabilities } from "@cadcamfun/plugin-sdk"
import { Effect } from "effect"

const raw = JSON.parse(await Bun.file(process.argv[2] ?? "plugin.json").text())
const manifest = await Effect.runPromise(decodeManifest(raw))   // throws a SchemaError naming the bad field
console.log(manifest.id, manifest.version, requestedCapabilities(manifest))
for (const tool of manifest.contributes?.aiTools ?? [])
  console.log(`plugin_${manifest.id}_${tool.id}`, tool.kind)
Common mistakeFix
"version": "1.0"Use three numbers: 1.0.0
"apiVersion": 1It is a string: "1"
Plugin id gear or My.PluginReverse-DNS, lower case: com.example.gear
Tool id Lookup-ThreadLower case, digits and underscores, letter first, ≤ 40 characters
Tool description shorter than 10 charactersDescribe what it does and when to use it
Declaring both drawing:read and drawing:writeAllowed, but drawing:write alone already implies read
Storage key starting with / or containing ..Use relative keys such as presets/m2

In 3D

Plugin tables Drag to orbit · scroll to zoom · right-drag to pan
PluginRegistryEntry and PluginConfiguration from the migrations: configurations reference registry entries.