Authoring plugins
This guide is for plugin authors. If you’re an operator looking to
install a plugin, see docs/plugins.md. If you’re
looking to extend browxai’s core surface (not write a plugin), the
contribution path is src/page/ / src/session/. Plugins are for
self-contained surfaces an outside team owns.
The plugin runtime ships as part of browxai’s v1.0 foundations. The contract is resolved-once-at-server-start, the loading model is in-process JS modules, and tool registration is globally namespaced.
What a plugin is
Section titled “What a plugin is”A plugin is a normal npm package with:
- A
"browxai"field on itspackage.jsoncarrying the manifest. - A JS entry module exporting a
register(api)function. - Zero, one, or many tools registered via
api.registerTool(name, def, handler).
That’s the entire contract. There is no decorator, no auto-discovery,
no class hierarchy. The minimum viable plugin is one file plus a
five-key package.json#browxai field.
The manifest (package.json#browxai)
Section titled “The manifest (package.json#browxai)”{ "name": "@browxai/plugin-example", "version": "0.1.0", "browxai": { "apiVersion": "1.0.0", "browxaiVersion": "^0.7.0", "namespace": "example", "register": "dist/index.js", "capabilities": [], "trust": "kalebtec", "dependsOn": [] }}Every field:
apiVersion(required): semver of the plugin-runtime contract this plugin codes against. The runtime advertisesRUNTIME_API_VERSION = "1.0.0". Your plugin’sapiVersionmust share the runtime’s major + have a minor ≤ runtime’s minor. A plugin built for1.0.0runs under runtime1.5.0; a plugin built for2.0.0does NOT run under runtime1.x(rejected at load).browxaiVersion(optional, advisory): semver range of the browxai host the plugin was tested against. Surfaced onplugins_list; never used to reject loading. If the running host’s version falls outside the range, the runtime logs a warning at load time (untested combination) and loads the plugin anyway.namespace(required): the tool prefix. Every tool the plugin registers MUST be<namespace>.<tool>. Namespace must match/^[a-z][a-z0-9_]*$/(lowercase, alphanumeric + underscore, starts with a letter). Reserved namespaces:browxai,browx,core,system,plugins. Two plugins claiming the same namespace BOTH fail with a clear error. Pick something project-unique.register(required): relative path to the JS entry module. The module must export aregister(api)function (named OR default). The runtime imports the module once at server start and callsregister(api)exactly once.capabilities(default[]): capabilities the plugin’s tools need. Subset of the operator’s enabled set at load time. Mismatch → plugin disabled (status: "disabled-by-capability-mismatch"), server still starts.trust(optional):kalebtec | community | local. Set explicitly on Kalebtec-maintained plugins. The CLI overrides on community / local installs based on the install source.dependsOn(default[]): other browxai plugins this one calls into. Each entry is{plugin: <npm-name>, version: <semver-range>}.
The register(api) function
Section titled “The register(api) function”export function register(api) { api.log.info("registering tools", { namespace: api.namespace });
api.registerTool( `${api.namespace}.echo`, { description: "Round-trip primitive.", inputSchema: {} }, async (args) => ({ content: [{ type: "text", text: JSON.stringify({ ok: true, result: args.msg ?? "" }) }], }), );}
export default register; // either named OR default export worksThe api argument exposes:
api.namespaceis your plugin’s namespace string.api.declaredCapabilitiesis the array you set in the manifest.api.registerTool(name, def, handler)registers a tool.nameMUST start with<namespace>.. Anything else throws synchronously.def.description: what the tool does, surfaced in MCPtools/list.def.inputSchema(optional): aRecord<string, ZodTypeAny>object. Same shape as core browxai tools use. Pass an empty object (or omit) for argless tools.handler(args)returns the MCP envelope{content:[...]}. Handlers should produce{ok:true,...}or{ok:false, error,...}JSON in the first text item, which matches the convention every core tool uses.
api.callTool(targetName, args?)calls another tool by name. Subject to call-graph enforcement (see below).api.log.{info,warn,error}is the plugin-scoped logger. Output is funnelled through the host’s structured logger withplugin=<name>attached. Plugins MUST NOT write to stdout/stderr directly: stdout is the MCP wire.
Namespace rule (why mandatory)
Section titled “Namespace rule (why mandatory)”Every plugin tool is <namespace>.<tool>. The bare name without a
prefix is rejected at registerTool time, even if the suffix
would otherwise be unique. This rule:
- Prevents plugins from overriding or wrapping core browxai tools.
The core surface lives in the implicit-root namespace, and a plugin
trying to register
clickwould fail. - Makes it obvious from a tool name alone which plugin owns it
(
figma.move_nodevscore_click). - Lets the SDK’s typed seam expose plugin tools under
client.plugins.<namespace>.<tool>for autocomplete.
Call-graph enforcement
Section titled “Call-graph enforcement”When your plugin calls api.callTool(target, args):
-
If
targetis a core browxai tool → allowed (implicit-root, always accessible). -
If
targetis one of your OWN tools → allowed. -
If
targetis a tool owned by a plugin in your transitively- resolveddependsOnset → allowed. -
Otherwise → rejected with a structured error:
{"ok": false,"error": "plugin call-graph violation: <fromPlugin> tried to call <targetPlugin>.<tool> but did not declare <targetPlugin> in dependsOn","code": "plugin-call-graph-violation","fromPlugin": "<your plugin>","targetPlugin": "<target>","targetTool": "<full tool name>","declaredDeps": [...],"hint": "Add { plugin, version } to dependsOn, reinstall, restart."}
This is the v1 inter-plugin composition contract. To call another
plugin’s tools, declare it in your package.json#browxai.dependsOn
with a version range:
"dependsOn": [ { "plugin": "@browxai/plugin-example", "version": "^0.1.0" }]The dep graph is built at server start; cycles abort startup loudly.
A missing dep, or one whose installed version doesn’t satisfy your
range, downgrades YOUR plugin to disabled-by-dep-missing. The
target plugin still loads.
Capability declarations
Section titled “Capability declarations”If your plugin’s tools need a capability beyond the default set
(read, navigation, action, human), declare it in
capabilities:
"capabilities": ["secrets"]At load time the runtime checks every declared capability is in the
server’s active set. Mismatch → your plugin is disabled with status
disabled-by-capability-mismatch and the reason surfaces on
plugins_list. The operator can fix by adding the capability to
BROWX_CAPABILITIES (or set_config({capabilities:[...]})) and
restarting the server. Capabilities are resolved ONCE at server
start.
At dispatch time every tool you register goes through the host’s
capability gate against your declared capabilities (specifically:
the first one). A call against the tool with the capability NOT in
the active set returns the same structured requiredCapability shape
core browxai tools return.
The v1 plugin-runtime contract gates the WHOLE plugin against the
declared capabilities list. Fine-grained per-tool capability
declarations may come in a future minor version.
Trust tiers
Section titled “Trust tiers”kalebtec: published by Kalebtec under@browxai/plugin-*. Reference plugins; same release/CI hygiene as browxai itself.community: third-party npm packages (browxai-plugin-*or@<org>/browxai-plugin-*).local: file-path-installed plugins. Used during plugin development (browxai plugin install file:./my-plugin/).
Trust is advisory: the runtime gates all three tiers identically
at capability + call-graph time. Surfaced on plugins_list so the
operator can audit.
Running a local plugin
Section titled “Running a local plugin”During development, install your plugin’s working directory by file path:
$ browxai plugin install file:./my-plugin/This shells out to pnpm add file:./my-plugin/ in the workspace’s
plugin install dir, writes the entry to plugins.json, pins the
content hash in plugins-lock.json, and tags the plugin’s trust
tier as local. Restart the browxai server for the change to
take effect: plugin lifecycle is resolved-once-at-server-start.
Local mode is the right shape for fast iteration: edit the plugin’s
source, rebuild it (pnpm build inside the plugin’s own dir),
restart the browxai server. There is no hot reload. The
restart is intentional, mirrors the capability lifecycle, and keeps
the per-plugin call-graph deterministic across requests.
Publishing to npm
Section titled “Publishing to npm”For Kalebtec plugins:
- Land the plugin under
packages/plugins/<name>/in thekalebteccom/browxairepo. - The release pipeline picks it up alongside the host package and
publishes
@browxai/plugin-<name>on npm under the official scope.
For community plugins:
- Use the package name shape
browxai-plugin-<name>or@<org>/browxai-plugin-<name>. Operators install by this name. - Ship a built
dist/directory and a typedschema.d.tsdescribing your tools’ arg/result shapes (see “SDK typing” below). - Publish the same way you’d publish any npm package
(
npm publish/pnpm publish).
Plugin lifecycle (resolved-once-at-server-start)
Section titled “Plugin lifecycle (resolved-once-at-server-start)”The plugin set is resolved exactly once, at server start. The
runtime reads plugins.json + each plugin’s package.json field +
the resolved-config-store plugins array, validates the graph,
topo-sorts the load order, runs every plugin’s register(api)
exactly once.
This means:
- No hot reload. Editing a plugin’s source while the server is running has zero effect. Restart.
set_config({plugins})persists but doesn’t apply until the next restart. ThepluginsPendingRestartflag onget_config({scope:"resolved"})flags the divergence.- Capability gating is server-startup-time, not per-call. A plugin disabled at load time stays disabled until restart.
- The call-graph is server-startup-time. A plugin that didn’t
declare another in
dependsOncannot acquire that permission mid- session.
This contract matches browxai’s capability lifecycle (same posture) and gives operators a deterministic surface to reason about.
SDK typing for plugin tools
Section titled “SDK typing for plugin tools”Plugin tools surface on the SDK via the typed escape hatch
client.callTool("<namespace>.<tool>", args) AND, more ergonomically,
via the namespaced proxy client.plugins.<namespace>.<tool>(args).
Plugins ship a TypeScript declaration overlay that consumers compose into their SDK client type:
// In the plugin's `schema.d.ts`:export interface ExamplePluginSchema { readonly example: { echo(args: { msg: string }): Promise<BrowxaiResult>; add(args: { a: number; b: number }): Promise<BrowxaiResult>; now(args?: Record<string, never>): Promise<BrowxaiResult>; };}// In the consumer's code:import type { ExamplePluginSchema } from "@browxai/plugin-example/schema";import type { BrowxaiClientWithPlugins } from "browxai";
const client = (await createBrowxai({...})) as BrowxaiClientWithPlugins<ExamplePluginSchema>;await client.plugins.example.echo({ msg: "hello" });This gives the consumer full autocomplete + arg typing on every plugin tool, while the runtime still rides the same dispatch path.
Worked example
Section titled “Worked example”See packages/plugins/example/ in the browxai repo for the
canonical reference plugin (@browxai/plugin-example). It
exercises every primitive listed above and is the source the keystone
test loads end-to-end.
The example:
- Three tools:
example.echo,example.add,example.now. - Empty
capabilitiesarray (no capability-gated work). - Empty
dependsOn(no other plugin called). - Trust tier
kalebtec(the manifest declares it; the CLI tags community/local installs based on source). - Vitest unit tests for the plugin handlers in isolation.
- A
schema.d.tsfor typed SDK access.
Copy that layout, change package.json#browxai, swap in your own
handlers. The runtime contract is identical regardless of what your
plugin actually does.
Real-world plugins
Section titled “Real-world plugins”The example plugin is the toy / learning path. The three canvas-app
adapter plugins show how a real first-party plugin consumes the
runtime: declared capabilities, an api.callTool("eval_js", …) inner
loop, structured app-not-loaded errors, and the typed schema overlay.
@browxai/plugin-figma: selection / viewport / node mutate / rectangle create over Figma’s page-sidefigma.*global.@browxai/plugin-tldraw: shapes / viewport / create / delete / select over Tldraw’swindow.editorglobal.@browxai/plugin-excalidraw: scene state / viewport / add / delete / scroll over Excalidraw’swindow.excalidrawAPIglobal.
Each is small (one register(api), five tool handlers, a unit-test
file, a README). They all share a common shape: declare eval + canvas
at the manifest level, route every tool through api.callTool("eval_js", {expr}), parse the envelope back to a structured {ok, …} shape, and
return a clear code:"<adapter>-not-loaded" error when the host app
isn’t on the page. That shape is the recommended pattern for any new
canvas-app adapter.
docs/plugins-first-party.md is the
operator-facing reference for these plugins: every op with args,
return shape, and error codes, plus a usage walkthrough.