How-to guidesPlugin system
Write a custom plugin
Guidelines to ship your custom plugin as a single npm package.
Entrypoints
Your package must expose two entrypoints:
.: client-side wrappers (Metro-safe, cross-platform)./plugin: worker-side plugin definition (Bare-only; addon imports allowed)
Example:
{
"name": "qvac-echo-plugin",
"exports": {
".": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js"
},
"./plugin": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
}
}
}Project structure
Keep client and plugin code split:
package.json
index.ts
index.ts
index.js
index.js
Bare and bundling constraints
Write plugin code that can be statically bundled. Guidelines:
- Avoid Node-only standard library usage in worker/plugin code.
- Avoid dynamic imports in worker/plugin code.
- Keep worker/plugin imports static and predictable.
Handler payload rules
Keep all handler I/O JSON-serializable. Guidelines:
- Request payloads must be plain JSON.
- Response payloads must be plain JSON.
- Avoid classes, Dates, Buffers, Maps/Sets, functions, and non-serializable types.
Client wrappers
Expose a wrapper-first API. Do not ask consumers to call invokePlugin directly. Guidelines:
- Export all public functions from the package root (
.). - Keep wrapper code Metro-safe.
- Forward calls to the worker using
invokePlugin/invokePluginStream. - Use typed request/response payloads.
- Prefer an options object signature (for example
{ modelId, ...params }) for consistency with SDK usage.
Example:
import { invokePlugin, invokePluginStream } from "@qvac/sdk";
export async function echo(options: { modelId: string; message: string }) {
return invokePlugin<{ echoed: string; timestamp: number }>({
modelId: options.modelId,
handler: "echo",
params: options,
});
}
export async function* echoStream(options: { modelId: string; message: string }) {
for await (const chunk of invokePluginStream<{ char: string | null; done: boolean }>({
modelId: options.modelId,
handler: "echoStream",
params: options,
})) {
if (!chunk.done && chunk.char) {
yield chunk.char;
}
}
}Worker-side plugin definition
Implement the worker-side plugin in the ./plugin entrypoint. Guidelines:
- Define the plugin with
definePluginand handlers withdefineHandler. - Use a unique
modelType. - Set a human-readable
displayName. - Set
addonPackageif your plugin uses an addon (use"none"for pure JS plugins). - Implement
createModel(params)usingparams.modelPathwhen your plugin requires model files.
Example:
import { z } from "zod";
import { definePlugin, defineHandler } from "@qvac/sdk/plugin-utils";
import type { CreateModelParams, PluginModelResult } from "@qvac/sdk";
export const echoPlugin = definePlugin({
modelType: "echo",
displayName: "Echo Plugin",
addonPackage: "none",
createModel: (params: CreateModelParams): PluginModelResult => {
const model = { id: params.modelId, load: async () => {} };
return { model, loader: null };
},
handlers: {
echo: defineHandler({
requestSchema: z.object({ message: z.string() }),
responseSchema: z.object({ echoed: z.string(), timestamp: z.number() }),
handler: async (request) => ({
echoed: `Echo: ${request.message}`,
timestamp: Date.now(),
}),
}),
},
});Local development (workspace linking)
Use workspace linking during development:
# In the plugin directory
bun link
# In the SDK/app project
bun link qvac-echo-pluginThen reference the plugin by package name.
Publishing checklist
- Package exports
.and./plugin. - Root entrypoint exports only Metro-safe client wrappers.
./pluginexports the worker-side plugin definition.- If your plugin requires model files,
createModelconsumes a resolvedmodelPath. - Handler requests and responses are JSON-serializable.
- Worker/plugin code is Bare-compatible and statically importable.