Clarify plugin authoring and external dev workflow

This commit is contained in:
Dotta
2026-03-14 10:40:21 -05:00
parent cb5d7e76fb
commit 30888759f2
36 changed files with 693 additions and 410 deletions

View File

@@ -22,11 +22,25 @@ Supported categories: `connector`, `workspace`, `automation`, `ui`
Generates:
- typed manifest + worker entrypoint
- example UI widget using `@paperclipai/plugin-sdk/ui`
- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks
- test file using `@paperclipai/plugin-sdk/testing`
- `esbuild` and `rollup` config files using SDK bundler presets
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
```bash
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
--output /absolute/path/to/plugins \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
That gives you an outside-repo local development path before the SDK is published to npm.
## Workflow after scaffolding
```bash

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
@@ -14,6 +16,7 @@ export interface ScaffoldPluginOptions {
description?: string;
author?: string;
category?: "connector" | "workspace" | "automation" | "ui";
sdkPath?: string;
}
/** Validate npm-style plugin package names (scoped or unscoped). */
@@ -55,6 +58,58 @@ function quote(value: string): string {
return JSON.stringify(value);
}
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function formatFileDependency(absPath: string): string {
return `file:${toPosixPath(path.resolve(absPath))}`;
}
function getLocalSdkPackagePath(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
}
function getRepoRootFromSdkPath(sdkPath: string): string {
return path.resolve(sdkPath, "..", "..", "..");
}
function getLocalSharedPackagePath(sdkPath: string): string {
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
}
function isInsideDir(targetPath: string, parentPath: string): boolean {
const relative = path.relative(parentPath, targetPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function packLocalPackage(packagePath: string, outputDir: string): string {
const packageJsonPath = path.join(packagePath, "package.json");
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`Package package.json not found at ${packageJsonPath}`);
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
name?: string;
version?: string;
};
const packageName = packageJson.name ?? path.basename(packagePath);
const packageVersion = packageJson.version ?? "0.0.0";
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
fs.mkdirSync(sdkBundleDir, { recursive: true });
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
const tarballPath = path.join(sdkBundleDir, tarballFileName);
if (!fs.existsSync(tarballPath)) {
throw new Error(`Packed tarball was not created at ${tarballPath}`);
}
return tarballPath;
}
/**
* Generate a complete Paperclip plugin starter project.
*
@@ -85,9 +140,18 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
const author = options.author ?? "Plugin Author";
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
const manifestId = packageToManifestId(options.pluginName);
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
fs.mkdirSync(outputDir, { recursive: true });
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
const sdkDependency = useWorkspaceSdk
? "workspace:*"
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
const packageJson = {
name: options.pluginName,
version: "0.1.0",
@@ -99,7 +163,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
"build:rollup": "rollup -c",
dev: "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
test: "vitest run",
test: "vitest run --config ./vitest.config.ts",
typecheck: "tsc --noEmit"
},
paperclipPlugin: {
@@ -110,10 +174,22 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
keywords: ["paperclip", "plugin", category],
author,
license: "MIT",
dependencies: {
"@paperclipai/plugin-sdk": "^1.0.0"
},
...(packedSharedTarball
? {
pnpm: {
overrides: {
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
},
},
}
: {}),
devDependencies: {
...(packedSharedTarball
? {
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
}
: {}),
"@paperclipai/plugin-sdk": sdkDependency,
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
@@ -144,7 +220,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
declarationMap: true,
sourceMap: true,
outDir: "dist",
rootDir: "src"
rootDir: "."
},
include: ["src", "tests"],
exclude: ["dist", "node_modules"]
@@ -207,6 +283,19 @@ export default [
`,
);
writeFile(
path.join(outputDir, "vitest.config.ts"),
`import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
`,
);
writeFile(
path.join(outputDir, "src", "manifest.ts"),
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
@@ -278,7 +367,7 @@ runWorker(plugin, import.meta.url);
writeFile(
path.join(outputDir, "src", "ui", "index.tsx"),
`import { MetricCard, StatusBadge, usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
type HealthData = {
status: "ok" | "degraded" | "error";
@@ -290,11 +379,13 @@ export function DashboardWidget(_props: PluginWidgetProps) {
const ping = usePluginAction("ping");
if (loading) return <div>Loading plugin health...</div>;
if (error) return <StatusBadge label={error.message} status="error" />;
if (error) return <div>Plugin error: {error.message}</div>;
return (
<div style={{ display: "grid", gap: "0.5rem" }}>
<MetricCard label="Health" value={data?.status ?? "unknown"} />
<strong>${displayName}</strong>
<div>Health: {data?.status ?? "unknown"}</div>
<div>Checked: {data?.checkedAt ?? "never"}</div>
<button onClick={() => void ping()}>Ping Worker</button>
</div>
);
@@ -342,10 +433,16 @@ pnpm dev:ui # local dev server with hot-reload events
pnpm test
\`\`\`
${sdkDependency.startsWith("file:")
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
: ""}
## Install Into Paperclip
\`\`\`bash
pnpm paperclipai plugin install ./
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
-H "Content-Type: application/json" \\
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
\`\`\`
## Build Options
@@ -355,7 +452,7 @@ pnpm paperclipai plugin install ./
`,
);
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n");
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
return outputDir;
}
@@ -371,7 +468,7 @@ function runCli() {
const pluginName = process.argv[2];
if (!pluginName) {
// eslint-disable-next-line no-console
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>]");
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
process.exit(1);
}
@@ -387,6 +484,7 @@ function runCli() {
description: parseArg("--description"),
author: parseArg("--author"),
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
sdkPath: parseArg("--sdk-path"),
});
// eslint-disable-next-line no-console

View File

@@ -0,0 +1,2 @@
dist
node_modules

View File

@@ -0,0 +1,23 @@
# Plugin Authoring Smoke Example
A Paperclip plugin
## Development
```bash
pnpm install
pnpm dev # watch builds
pnpm dev:ui # local dev server with hot-reload events
pnpm test
```
## Install Into Paperclip
```bash
pnpm paperclipai plugin install ./
```
## Build Options
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
- `pnpm build:rollup` uses rollup presets from the same SDK.

View File

@@ -0,0 +1,17 @@
import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}

View File

@@ -0,0 +1,44 @@
{
"name": "@paperclipai/plugin-authoring-smoke-example",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "A Paperclip plugin",
"scripts": {
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"dev": "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"connector"
],
"author": "Plugin Author",
"license": "MIT",
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"esbuild": "^0.27.3",
"rollup": "^4.38.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18"
}
}

View File

@@ -0,0 +1,28 @@
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
function withPlugins(config) {
if (!config) return null;
return {
...config,
plugins: [
nodeResolve({
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
}),
typescript({
tsconfig: "./tsconfig.json",
declaration: false,
declarationMap: false,
}),
],
};
}
export default [
withPlugins(presets.rollup.manifest),
withPlugins(presets.rollup.worker),
withPlugins(presets.rollup.ui),
].filter(Boolean);

View File

@@ -0,0 +1,32 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "paperclipai.plugin-authoring-smoke-example",
apiVersion: 1,
version: "0.1.0",
displayName: "Plugin Authoring Smoke Example",
description: "A Paperclip plugin",
author: "Plugin Author",
categories: ["connector"],
capabilities: [
"events.subscribe",
"plugin.state.read",
"plugin.state.write"
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui"
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "health-widget",
displayName: "Plugin Authoring Smoke Example Health",
exportName: "DashboardWidget"
}
]
}
};
export default manifest;

View File

@@ -0,0 +1,23 @@
import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
type HealthData = {
status: "ok" | "degraded" | "error";
checkedAt: string;
};
export function DashboardWidget(_props: PluginWidgetProps) {
const { data, loading, error } = usePluginData<HealthData>("health");
const ping = usePluginAction("ping");
if (loading) return <div>Loading plugin health...</div>;
if (error) return <div>Plugin error: {error.message}</div>;
return (
<div style={{ display: "grid", gap: "0.5rem" }}>
<strong>Plugin Authoring Smoke Example</strong>
<div>Health: {data?.status ?? "unknown"}</div>
<div>Checked: {data?.checkedAt ?? "never"}</div>
<button onClick={() => void ping()}>Ping Worker</button>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
const plugin = definePlugin({
async setup(ctx) {
ctx.events.on("issue.created", async (event) => {
const issueId = event.entityId ?? "unknown";
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
ctx.logger.info("Observed issue.created", { issueId });
});
ctx.data.register("health", async () => {
return { status: "ok", checkedAt: new Date().toISOString() };
});
ctx.actions.register("ping", async () => {
ctx.logger.info("Ping action invoked");
return { pong: true, at: new Date().toISOString() };
});
},
async onHealth() {
return { status: "ok", message: "Plugin worker is running" };
}
});
export default plugin;
runWorker(plugin, import.meta.url);

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
describe("plugin scaffold", () => {
it("registers data + actions and handles events", async () => {
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
await plugin.definition.setup(harness.ctx);
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
const data = await harness.getData<{ status: string }>("health");
expect(data.status).toBe("ok");
const action = await harness.performAction<{ pong: boolean }>("ping");
expect(action.pong).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});

View File

@@ -39,8 +39,6 @@ const manifest: PaperclipPluginManifestV1 = {
"goals.read",
"goals.create",
"goals.update",
"assets.write",
"assets.read",
"activity.log.write",
"metrics.write",
"plugin.state.read",

View File

@@ -71,7 +71,6 @@ type OverviewData = {
};
lastJob: unknown;
lastWebhook: unknown;
lastAsset: unknown;
lastProcessResult: unknown;
streamChannels: Record<string, string>;
safeCommands: Array<{ key: string; label: string; description: string }>;
@@ -766,7 +765,6 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
value={{
lastJob: overview.data?.lastJob ?? null,
lastWebhook: overview.data?.lastWebhook ?? null,
lastAsset: overview.data?.lastAsset ?? null,
lastProcessResult: overview.data?.lastProcessResult ?? null,
}}
/>
@@ -1340,7 +1338,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
const [secretRef, setSecretRef] = useState("");
const [metricName, setMetricName] = useState("manual");
const [metricValue, setMetricValue] = useState("1");
const [assetContent, setAssetContent] = useState("Kitchen Sink asset demo");
const [workspaceId, setWorkspaceId] = useState("");
const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile);
const [workspaceContent, setWorkspaceContent] = useState("Kitchen Sink wrote this file.");
@@ -1388,7 +1385,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
const writeMetric = usePluginAction("write-metric");
const httpFetch = usePluginAction("http-fetch");
const resolveSecret = usePluginAction("resolve-secret");
const createAsset = usePluginAction("create-asset");
const runProcess = usePluginAction("run-process");
const readWorkspaceFile = usePluginAction("read-workspace-file");
const writeWorkspaceScratch = usePluginAction("write-workspace-scratch");
@@ -1808,7 +1804,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
</div>
</Section>
<Section title="HTTP + Secrets + Assets + Activity + Metrics">
<Section title="HTTP + Secrets + Activity + Metrics">
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<form
style={layoutStack}
@@ -1836,22 +1832,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
<input style={inputStyle} value={secretRef} onChange={(event) => setSecretRef(event.target.value)} placeholder="MY_SECRET_REF" />
<button type="submit" style={buttonStyle}>Resolve secret ref</button>
</form>
<form
style={layoutStack}
onSubmit={(event) => {
event.preventDefault();
void createAsset({ filename: "kitchen-sink-demo.txt", content: assetContent })
.then((next) => {
setResult(next);
overview.refresh();
})
.catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) }));
}}
>
<strong>Assets</strong>
<textarea style={{ ...inputStyle, minHeight: "88px" }} value={assetContent} onChange={(event) => setAssetContent(event.target.value)} />
<button type="submit" style={buttonStyle}>Upload text asset</button>
</form>
<form
style={layoutStack}
onSubmit={(event) => {

View File

@@ -262,7 +262,6 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
const lastJob = await readInstanceState(ctx, "last-job-run");
const lastWebhook = await readInstanceState(ctx, "last-webhook");
const lastAsset = await readInstanceState(ctx, "last-asset");
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
return {
pluginId: PLUGIN_ID,
@@ -281,7 +280,6 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
},
lastJob,
lastWebhook,
lastAsset,
lastProcessResult,
streamChannels: STREAM_CHANNELS,
safeCommands: SAFE_COMMANDS,
@@ -619,24 +617,6 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
};
});
ctx.actions.register("create-asset", async (params) => {
const filename = typeof params.filename === "string" && params.filename.length > 0
? params.filename
: `kitchen-sink-${Date.now()}.txt`;
const content = typeof params.content === "string" ? params.content : "Kitchen Sink example asset";
const uploaded = await ctx.assets.upload(filename, "text/plain", Buffer.from(content, "utf8"));
const url = await ctx.assets.getUrl(uploaded.assetId);
const result = { ...uploaded, url };
await writeInstanceState(ctx, "last-asset", result);
pushRecord({
level: "info",
source: "assets",
message: `Uploaded asset ${filename}`,
data: { assetId: uploaded.assetId },
});
return result;
});
ctx.actions.register("run-process", async (params) => {
const config = await getConfig(ctx);
const companyId = getCurrentCompanyId(params);

View File

@@ -3,7 +3,7 @@
Official TypeScript SDK for Paperclip plugin authors.
- **Worker SDK:** `@paperclipai/plugin-sdk``definePlugin`, context, lifecycle
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks, components, slot props
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks and slot props
- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
@@ -15,10 +15,9 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
| Import | Purpose |
|--------|--------|
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, shared components |
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
| `@paperclipai/plugin-sdk/ui/components` | `MetricCard`, `StatusBadge`, `Spinner`, `ErrorBoundary`, etc. |
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
@@ -42,10 +41,14 @@ pnpm add @paperclipai/plugin-sdk
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
- Plugin workers and plugin UI should both be treated as trusted code today.
- Plugin UI bundles run as same-origin JavaScript inside the main Paperclip app. They can call ordinary Paperclip HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.
- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
@@ -97,7 +100,7 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `assets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. All host APIs are capability-gated; declare capabilities in the manifest.
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
@@ -126,7 +129,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
**Company-scoped delivery:** Events with a `companyId` are only delivered to plugins that are enabled for that company. If a company has disabled a plugin via settings, that plugin's handlers will not receive events belonging to that company. Events without a `companyId` are delivered to all subscribers.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
## Scheduled (recurring) jobs
@@ -302,8 +305,6 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `issues.create` |
| | `issues.update` |
| | `issue.comments.create` |
| | `assets.write` |
| | `assets.read` |
| | `activity.log.write` |
| | `metrics.write` |
| **Instance** | `instance.settings.register` |
@@ -333,14 +334,15 @@ Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
## UI quick start
```tsx
import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
export function DashboardWidget() {
const { data } = usePluginData<{ status: string }>("health");
const ping = usePluginAction("ping");
return (
<div>
<MetricCard label="Health" value={data?.status ?? "unknown"} />
<div style={{ display: "grid", gap: 8 }}>
<strong>Health</strong>
<div>{data?.status ?? "unknown"}</div>
<button onClick={() => void ping()}>Ping</button>
</div>
);
@@ -354,7 +356,7 @@ export function DashboardWidget() {
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
```tsx
import { usePluginData, Spinner, StatusBadge } from "@paperclipai/plugin-sdk/ui";
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
interface SyncStatus {
lastSyncAt: string;
@@ -367,12 +369,12 @@ export function SyncStatusWidget({ context }: PluginWidgetProps) {
companyId: context.companyId,
});
if (loading) return <Spinner />;
if (error) return <StatusBadge label={error.message} status="error" />;
if (loading) return <div>Loading</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<StatusBadge label={data!.healthy ? "Healthy" : "Unhealthy"} status={data!.healthy ? "ok" : "error"} />
<p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
<p>Synced {data!.syncedCount} items</p>
<p>Last sync: {data!.lastSyncAt}</p>
<button onClick={refresh}>Refresh</button>
@@ -465,100 +467,9 @@ export function ChatMessages({ context }: PluginWidgetProps) {
The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
### Shared components reference
### UI authoring note
All components are provided by the host at runtime and match the host design tokens. Import from `@paperclipai/plugin-sdk/ui` or `@paperclipai/plugin-sdk/ui/components`.
#### `MetricCard`
Displays a single metric value with optional trend and sparkline.
```tsx
<MetricCard label="Issues Synced" value={142} unit="issues" trend={{ direction: "up", percentage: 12 }} />
<MetricCard label="API Latency" value="45ms" sparkline={[52, 48, 45, 47, 45]} />
```
#### `StatusBadge`
Inline status indicator with semantic color.
```tsx
<StatusBadge label="Connected" status="ok" />
<StatusBadge label="Rate Limited" status="warning" />
<StatusBadge label="Auth Failed" status="error" />
```
#### `DataTable`
Sortable, paginated table.
```tsx
<DataTable
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", width: "100px" },
{ key: "updatedAt", header: "Updated", render: (v) => new Date(v as string).toLocaleDateString() },
]}
rows={issues}
totalCount={totalCount}
page={page}
pageSize={25}
onPageChange={setPage}
onSort={(key, dir) => setSortBy({ key, dir })}
/>
```
#### `TimeseriesChart`
Line or bar chart for time-series data.
```tsx
<TimeseriesChart
title="Sync Frequency"
data={[
{ timestamp: "2026-03-01T00:00:00Z", value: 24 },
{ timestamp: "2026-03-02T00:00:00Z", value: 31 },
{ timestamp: "2026-03-03T00:00:00Z", value: 28 },
]}
type="bar"
yLabel="Syncs"
height={250}
/>
```
#### `ActionBar`
Row of action buttons wired to the plugin bridge.
```tsx
<ActionBar
actions={[
{ label: "Sync Now", actionKey: "sync", variant: "primary" },
{ label: "Clear Cache", actionKey: "clear-cache", confirm: true, confirmMessage: "Delete all cached data?" },
]}
onSuccess={(key) => data.refresh()}
onError={(key, err) => console.error(key, err)}
/>
```
#### `LogView`, `JsonTree`, `KeyValueList`, `MarkdownBlock`
```tsx
<LogView entries={logEntries} maxHeight="300px" autoScroll />
<JsonTree data={debugPayload} defaultExpandDepth={3} />
<KeyValueList pairs={[{ label: "Plugin ID", value: pluginId }, { label: "Version", value: "1.2.0" }]} />
<MarkdownBlock content="**Bold** text and `code` blocks are supported." />
```
#### `Spinner`, `ErrorBoundary`
```tsx
<Spinner size="lg" label="Loading plugin data..." />
<ErrorBoundary fallback={<p>Something went wrong.</p>} onError={(err) => console.error(err)}>
<MyPluginContent />
</ErrorBoundary>
```
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
### Slot component props
@@ -579,7 +490,7 @@ Example detail tab with entity context:
```tsx
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, KeyValueList, Spinner } from "@paperclipai/plugin-sdk/ui";
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
export function AgentMetricsTab({ context }: PluginDetailTabProps) {
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
@@ -587,13 +498,18 @@ export function AgentMetricsTab({ context }: PluginDetailTabProps) {
companyId: context.companyId,
});
if (loading) return <Spinner />;
if (loading) return <div>Loading</div>;
if (!data) return <p>No metrics available.</p>;
return (
<KeyValueList
pairs={Object.entries(data).map(([label, value]) => ({ label, value }))}
/>
<dl>
{Object.entries(data).map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
```
@@ -702,8 +618,6 @@ For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal i
```tsx
import { useState } from "react";
import {
ErrorBoundary,
Spinner,
useHostContext,
usePluginAction,
} from "@paperclipai/plugin-sdk/ui";
@@ -730,7 +644,7 @@ export function SyncToolbarButton() {
}
return (
<ErrorBoundary>
<>
<button type="button" onClick={() => setOpen(true)}>
Sync
</button>
@@ -757,13 +671,13 @@ export function SyncToolbarButton() {
Cancel
</button>
<button type="button" onClick={() => void confirm()} disabled={submitting}>
{submitting ? <Spinner size="sm" /> : "Run sync"}
{submitting ? "Running…" : "Run sync"}
</button>
</div>
</div>
</div>
) : null}
</ErrorBoundary>
</>
);
}
```

View File

@@ -28,10 +28,6 @@
"types": "./dist/ui/types.d.ts",
"import": "./dist/ui/types.js"
},
"./ui/components": {
"types": "./dist/ui/components.d.ts",
"import": "./dist/ui/components.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
@@ -75,10 +71,6 @@
"types": "./dist/ui/types.d.ts",
"import": "./dist/ui/types.js"
},
"./ui/components": {
"types": "./dist/ui/components.d.ts",
"import": "./dist/ui/components.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"

View File

@@ -62,7 +62,6 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
const uiExternal = [
"@paperclipai/plugin-sdk/ui",
"@paperclipai/plugin-sdk/ui/hooks",
"@paperclipai/plugin-sdk/ui/components",
"react",
"react-dom",
"react/jsx-runtime",
@@ -84,7 +83,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
target: "node20",
sourcemap,
minify,
external: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "react", "react-dom"],
external: ["react", "react-dom"],
};
const esbuildManifest: EsbuildLikeOptions = {
@@ -119,7 +118,7 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
sourcemap,
entryFileNames: "worker.js",
},
external: ["@paperclipai/plugin-sdk", "react", "react-dom"],
external: ["react", "react-dom"],
};
const rollupManifest: RollupLikeConfig = {

View File

@@ -118,12 +118,6 @@ export interface HostServices {
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
};
/** Provides `assets.upload`, `assets.getUrl`. */
assets: {
upload(params: WorkerToHostMethods["assets.upload"][0]): Promise<WorkerToHostMethods["assets.upload"][1]>;
getUrl(params: WorkerToHostMethods["assets.getUrl"][0]): Promise<string>;
};
/** Provides `activity.log`. */
activity: {
log(params: {
@@ -274,10 +268,6 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
// Secrets
"secrets.resolve": "secrets.read-ref",
// Assets
"assets.upload": "assets.write",
"assets.getUrl": "assets.read",
// Activity
"activity.log": "activity.log.write",
@@ -428,14 +418,6 @@ export function createHostClientHandlers(
return services.secrets.resolve(params);
}),
// Assets
"assets.upload": gated("assets.upload", async (params) => {
return services.assets.upload(params);
}),
"assets.getUrl": gated("assets.getUrl", async (params) => {
return services.assets.getUrl(params);
}),
// Activity
"activity.log": gated("activity.log", async (params) => {
return services.activity.log(params);

View File

@@ -164,7 +164,6 @@ export type {
PluginLaunchersClient,
PluginHttpClient,
PluginSecretsClient,
PluginAssetsClient,
PluginActivityClient,
PluginActivityLogEntry,
PluginStateClient,

View File

@@ -495,16 +495,6 @@ export interface WorkerToHostMethods {
result: string,
];
// Assets
"assets.upload": [
params: { filename: string; contentType: string; data: string },
result: { assetId: string; url: string },
];
"assets.getUrl": [
params: { assetId: string },
result: string,
];
// Activity
"activity.log": [
params: {

View File

@@ -136,8 +136,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const state = new Map<string, unknown>();
const entities = new Map<string, PluginEntityRecord>();
const entityExternalIndex = new Map<string, string>();
const assets = new Map<string, { contentType: string; data: Uint8Array }>();
const companies = new Map<string, Company>();
const projects = new Map<string, Project>();
const issues = new Map<string, Issue>();
@@ -207,19 +205,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return `resolved:${secretRef}`;
},
},
assets: {
async upload(filename, contentType, data) {
requireCapability(manifest, capabilitySet, "assets.write");
const assetId = `asset_${randomUUID()}`;
assets.set(assetId, { contentType, data: data instanceof Uint8Array ? data : new Uint8Array(data) });
return { assetId, url: `memory://assets/${filename}` };
},
async getUrl(assetId) {
requireCapability(manifest, capabilitySet, "assets.read");
if (!assets.has(assetId)) throw new Error(`Asset not found: ${assetId}`);
return `memory://assets/${assetId}`;
},
},
activity: {
async log(entry) {
requireCapability(manifest, capabilitySet, "activity.log.write");

View File

@@ -452,34 +452,6 @@ export interface PluginSecretsClient {
resolve(secretRef: string): Promise<string>;
}
/**
* `ctx.assets` — read and write assets (files, images, etc.).
*
* `assets.read` capability required for `getUrl()`.
* `assets.write` capability required for `upload()`.
*
* @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write
*/
export interface PluginAssetsClient {
/**
* Upload an asset (e.g. a screenshot or generated file).
*
* @param filename - Name for the asset file
* @param contentType - MIME type
* @param data - Raw asset data as a Buffer or Uint8Array
* @returns The asset ID and public URL
*/
upload(filename: string, contentType: string, data: Buffer | Uint8Array): Promise<{ assetId: string; url: string }>;
/**
* Get the public URL for an existing asset by ID.
*
* @param assetId - Asset identifier
* @returns The public URL
*/
getUrl(assetId: string): Promise<string>;
}
/**
* Input for writing a plugin activity log entry.
*
@@ -1069,9 +1041,6 @@ export interface PluginContext {
/** Resolve secret references. Requires `secrets.read-ref`. */
secrets: PluginSecretsClient;
/** Read and write assets. Requires `assets.read` / `assets.write`. */
assets: PluginAssetsClient;
/** Write activity log entries. Requires `activity.log.write`. */
activity: PluginActivityClient;

View File

@@ -29,9 +29,9 @@ import { getSdkUiRuntimeValue } from "./runtime.js";
* companyId: context.companyId,
* });
*
* if (loading) return <Spinner />;
* if (loading) return <div>Loading…</div>;
* if (error) return <div>Error: {error.message}</div>;
* return <MetricCard label="Synced Issues" value={data!.syncedCount} />;
* return <div>Synced Issues: {data!.syncedCount}</div>;
* }
* ```
*

View File

@@ -12,14 +12,7 @@
* @example
* ```tsx
* // Plugin UI bundle entry (dist/ui/index.tsx)
* import {
* usePluginData,
* usePluginAction,
* useHostContext,
* MetricCard,
* StatusBadge,
* Spinner,
* } from "@paperclipai/plugin-sdk/ui";
* import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
*
* export function DashboardWidget({ context }: PluginWidgetProps) {
@@ -28,12 +21,13 @@
* });
* const resync = usePluginAction("resync");
*
* if (loading) return <Spinner />;
* if (loading) return <div>Loading…</div>;
* if (error) return <div>Error: {error.message}</div>;
*
* return (
* <div>
* <MetricCard label="Synced Issues" value={data!.syncedCount} />
* <div style={{ display: "grid", gap: 8 }}>
* <strong>Synced Issues</strong>
* <div>{data!.syncedCount}</div>
* <button onClick={() => resync({ companyId: context.companyId })}>
* Resync Now
* </button>
@@ -91,40 +85,3 @@ export type {
PluginCommentContextMenuItemProps,
PluginSettingsPageProps,
} from "./types.js";
// Shared UI components
export {
MetricCard,
StatusBadge,
DataTable,
TimeseriesChart,
MarkdownBlock,
KeyValueList,
ActionBar,
LogView,
JsonTree,
Spinner,
ErrorBoundary,
} from "./components.js";
// Shared component prop types (for plugin authors who need to extend them)
export type {
MetricCardProps,
MetricTrend,
StatusBadgeProps,
StatusBadgeVariant,
DataTableProps,
DataTableColumn,
TimeseriesChartProps,
TimeseriesDataPoint,
MarkdownBlockProps,
KeyValueListProps,
KeyValuePair,
ActionBarProps,
ActionBarItem,
LogViewProps,
LogViewEntry,
JsonTreeProps,
SpinnerProps,
ErrorBoundaryProps,
} from "./components.js";

View File

@@ -456,26 +456,6 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
assets: {
async upload(
filename: string,
contentType: string,
data: Buffer | Uint8Array,
): Promise<{ assetId: string; url: string }> {
// Base64-encode binary data for JSON serialization
const base64 = Buffer.from(data).toString("base64");
return callHost("assets.upload", {
filename,
contentType,
data: base64,
});
},
async getUrl(assetId: string): Promise<string> {
return callHost("assets.getUrl", { assetId });
},
},
activity: {
async log(entry): Promise<void> {
await callHost("activity.log", {