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

@@ -0,0 +1,154 @@
# Plugin Authoring Guide
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
## Current reality
- Treat plugin workers and plugin UI as trusted code.
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
- Worker-side host APIs are capability-gated.
- Plugin UI is not sandboxed by manifest capabilities.
- There is no host-provided shared React component kit for plugins yet.
- `ctx.assets` is not supported in the current runtime.
## Scaffold a plugin
Use the scaffold package:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
```
For a plugin that lives outside the Paperclip repo:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
--output /absolute/path/to/plugin-repos \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
That creates a package with:
- `src/manifest.ts`
- `src/worker.ts`
- `src/ui/index.tsx`
- `tests/plugin.spec.ts`
- `esbuild.config.mjs`
- `rollup.config.mjs`
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
## Recommended local workflow
From the generated plugin folder:
```bash
pnpm install
pnpm typecheck
pnpm test
pnpm build
```
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
Example:
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
```
## Supported alpha surface
Worker:
- config
- events
- jobs
- launchers
- http
- secrets
- activity
- state
- entities
- projects and project workspaces
- companies
- issues and comments
- agents and agent sessions
- goals
- data/actions
- streams
- tools
- metrics
- logger
UI:
- `usePluginData`
- `usePluginAction`
- `usePluginStream`
- `usePluginToast`
- `useHostContext`
- typed slot props from `@paperclipai/plugin-sdk/ui`
Mount surfaces currently wired in the host include:
- `page`
- `settingsPage`
- `dashboardWidget`
- `sidebar`
- `sidebarPanel`
- `detailTab`
- `taskDetailView`
- `projectSidebarItem`
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
- `commentContextMenuItem`
## Company routes
Plugins may declare a `page` slot with `routePath` to own a company route like:
```text
/:companyPrefix/<routePath>
```
Rules:
- `routePath` must be a single lowercase slug
- it cannot collide with reserved host routes
- it cannot duplicate another installed plugin page route
## Publishing guidance
- Use npm packages as the deployment artifact.
- Treat repo-local example installs as a development workflow only.
- Prefer keeping plugin UI self-contained inside the package.
- Do not rely on host design-system components or undocumented app internals.
- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry.
## Verification before handoff
At minimum:
```bash
pnpm --filter <your-plugin-package> typecheck
pnpm --filter <your-plugin-package> test
pnpm --filter <your-plugin-package> build
```
If you changed host integration too, also run:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```

View File

@@ -20,11 +20,14 @@ Today, the practical deployment model is:
Current limitations to keep in mind: Current limitations to keep in mind:
- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary.
- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly.
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory. - Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry. - Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
- Published npm packages are the intended install artifact for deployed plugins. - Published npm packages are the intended install artifact for deployed plugins.
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build. - The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet. - Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution. In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.

View File

@@ -22,11 +22,25 @@ Supported categories: `connector`, `workspace`, `automation`, `ui`
Generates: Generates:
- typed manifest + worker entrypoint - 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` - test file using `@paperclipai/plugin-sdk/testing`
- `esbuild` and `rollup` config files using SDK bundler presets - `esbuild` and `rollup` config files using SDK bundler presets
- dev server script for hot-reload (`paperclip-plugin-dev-server`) - 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 ## Workflow after scaffolding
```bash ```bash

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const; const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
type PluginTemplate = (typeof VALID_TEMPLATES)[number]; type PluginTemplate = (typeof VALID_TEMPLATES)[number];
@@ -14,6 +16,7 @@ export interface ScaffoldPluginOptions {
description?: string; description?: string;
author?: string; author?: string;
category?: "connector" | "workspace" | "automation" | "ui"; category?: "connector" | "workspace" | "automation" | "ui";
sdkPath?: string;
} }
/** Validate npm-style plugin package names (scoped or unscoped). */ /** Validate npm-style plugin package names (scoped or unscoped). */
@@ -55,6 +58,58 @@ function quote(value: string): string {
return JSON.stringify(value); 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. * Generate a complete Paperclip plugin starter project.
* *
@@ -85,9 +140,18 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
const author = options.author ?? "Plugin Author"; const author = options.author ?? "Plugin Author";
const category = options.category ?? (template === "workspace" ? "workspace" : "connector"); const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
const manifestId = packageToManifestId(options.pluginName); 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 }); 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 = { const packageJson = {
name: options.pluginName, name: options.pluginName,
version: "0.1.0", version: "0.1.0",
@@ -99,7 +163,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
"build:rollup": "rollup -c", "build:rollup": "rollup -c",
dev: "node ./esbuild.config.mjs --watch", dev: "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", "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" typecheck: "tsc --noEmit"
}, },
paperclipPlugin: { paperclipPlugin: {
@@ -110,10 +174,22 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
keywords: ["paperclip", "plugin", category], keywords: ["paperclip", "plugin", category],
author, author,
license: "MIT", license: "MIT",
dependencies: { ...(packedSharedTarball
"@paperclipai/plugin-sdk": "^1.0.0" ? {
}, pnpm: {
overrides: {
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
},
},
}
: {}),
devDependencies: { devDependencies: {
...(packedSharedTarball
? {
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
}
: {}),
"@paperclipai/plugin-sdk": sdkDependency,
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2", "@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
@@ -144,7 +220,7 @@ export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
declarationMap: true, declarationMap: true,
sourceMap: true, sourceMap: true,
outDir: "dist", outDir: "dist",
rootDir: "src" rootDir: "."
}, },
include: ["src", "tests"], include: ["src", "tests"],
exclude: ["dist", "node_modules"] 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( writeFile(
path.join(outputDir, "src", "manifest.ts"), path.join(outputDir, "src", "manifest.ts"),
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; `import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
@@ -278,7 +367,7 @@ runWorker(plugin, import.meta.url);
writeFile( writeFile(
path.join(outputDir, "src", "ui", "index.tsx"), 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 = { type HealthData = {
status: "ok" | "degraded" | "error"; status: "ok" | "degraded" | "error";
@@ -290,11 +379,13 @@ export function DashboardWidget(_props: PluginWidgetProps) {
const ping = usePluginAction("ping"); const ping = usePluginAction("ping");
if (loading) return <div>Loading plugin health...</div>; 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 ( return (
<div style={{ display: "grid", gap: "0.5rem" }}> <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> <button onClick={() => void ping()}>Ping Worker</button>
</div> </div>
); );
@@ -342,10 +433,16 @@ pnpm dev:ui # local dev server with hot-reload events
pnpm test 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 ## Install Into Paperclip
\`\`\`bash \`\`\`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 ## 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; return outputDir;
} }
@@ -371,7 +468,7 @@ function runCli() {
const pluginName = process.argv[2]; const pluginName = process.argv[2];
if (!pluginName) { if (!pluginName) {
// eslint-disable-next-line no-console // 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); process.exit(1);
} }
@@ -387,6 +484,7 @@ function runCli() {
description: parseArg("--description"), description: parseArg("--description"),
author: parseArg("--author"), author: parseArg("--author"),
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined, category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
sdkPath: parseArg("--sdk-path"),
}); });
// eslint-disable-next-line no-console // 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.read",
"goals.create", "goals.create",
"goals.update", "goals.update",
"assets.write",
"assets.read",
"activity.log.write", "activity.log.write",
"metrics.write", "metrics.write",
"plugin.state.read", "plugin.state.read",

View File

@@ -71,7 +71,6 @@ type OverviewData = {
}; };
lastJob: unknown; lastJob: unknown;
lastWebhook: unknown; lastWebhook: unknown;
lastAsset: unknown;
lastProcessResult: unknown; lastProcessResult: unknown;
streamChannels: Record<string, string>; streamChannels: Record<string, string>;
safeCommands: Array<{ key: string; label: string; description: string }>; safeCommands: Array<{ key: string; label: string; description: string }>;
@@ -766,7 +765,6 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
value={{ value={{
lastJob: overview.data?.lastJob ?? null, lastJob: overview.data?.lastJob ?? null,
lastWebhook: overview.data?.lastWebhook ?? null, lastWebhook: overview.data?.lastWebhook ?? null,
lastAsset: overview.data?.lastAsset ?? null,
lastProcessResult: overview.data?.lastProcessResult ?? null, lastProcessResult: overview.data?.lastProcessResult ?? null,
}} }}
/> />
@@ -1340,7 +1338,6 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
const [secretRef, setSecretRef] = useState(""); const [secretRef, setSecretRef] = useState("");
const [metricName, setMetricName] = useState("manual"); const [metricName, setMetricName] = useState("manual");
const [metricValue, setMetricValue] = useState("1"); const [metricValue, setMetricValue] = useState("1");
const [assetContent, setAssetContent] = useState("Kitchen Sink asset demo");
const [workspaceId, setWorkspaceId] = useState(""); const [workspaceId, setWorkspaceId] = useState("");
const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile); const [workspacePath, setWorkspacePath] = useState<string>(DEFAULT_CONFIG.workspaceScratchFile);
const [workspaceContent, setWorkspaceContent] = useState("Kitchen Sink wrote this file."); 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 writeMetric = usePluginAction("write-metric");
const httpFetch = usePluginAction("http-fetch"); const httpFetch = usePluginAction("http-fetch");
const resolveSecret = usePluginAction("resolve-secret"); const resolveSecret = usePluginAction("resolve-secret");
const createAsset = usePluginAction("create-asset");
const runProcess = usePluginAction("run-process"); const runProcess = usePluginAction("run-process");
const readWorkspaceFile = usePluginAction("read-workspace-file"); const readWorkspaceFile = usePluginAction("read-workspace-file");
const writeWorkspaceScratch = usePluginAction("write-workspace-scratch"); const writeWorkspaceScratch = usePluginAction("write-workspace-scratch");
@@ -1808,7 +1804,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
</div> </div>
</Section> </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))" }}> <div style={{ display: "grid", gap: "12px", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<form <form
style={layoutStack} 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" /> <input style={inputStyle} value={secretRef} onChange={(event) => setSecretRef(event.target.value)} placeholder="MY_SECRET_REF" />
<button type="submit" style={buttonStyle}>Resolve secret ref</button> <button type="submit" style={buttonStyle}>Resolve secret ref</button>
</form> </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 <form
style={layoutStack} style={layoutStack}
onSubmit={(event) => { 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 agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
const lastJob = await readInstanceState(ctx, "last-job-run"); const lastJob = await readInstanceState(ctx, "last-job-run");
const lastWebhook = await readInstanceState(ctx, "last-webhook"); const lastWebhook = await readInstanceState(ctx, "last-webhook");
const lastAsset = await readInstanceState(ctx, "last-asset");
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery); const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
return { return {
pluginId: PLUGIN_ID, pluginId: PLUGIN_ID,
@@ -281,7 +280,6 @@ async function registerDataHandlers(ctx: PluginContext): Promise<void> {
}, },
lastJob, lastJob,
lastWebhook, lastWebhook,
lastAsset,
lastProcessResult, lastProcessResult,
streamChannels: STREAM_CHANNELS, streamChannels: STREAM_CHANNELS,
safeCommands: SAFE_COMMANDS, 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) => { ctx.actions.register("run-process", async (params) => {
const config = await getConfig(ctx); const config = await getConfig(ctx);
const companyId = getCurrentCompanyId(params); const companyId = getCurrentCompanyId(params);

View File

@@ -3,7 +3,7 @@
Official TypeScript SDK for Paperclip plugin authors. Official TypeScript SDK for Paperclip plugin authors.
- **Worker SDK:** `@paperclipai/plugin-sdk``definePlugin`, context, lifecycle - **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 - **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets - **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload - **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
@@ -15,10 +15,9 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
| Import | Purpose | | Import | Purpose |
|--------|--------| |--------|--------|
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers | | `@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/hooks` | Hooks only |
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces | | `@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/testing` | `createTestHarness` for unit/integration tests |
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds | | `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` | | `@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. 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. - 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. - 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. - 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. - 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. 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. | | `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. | | `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. **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. **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 ## Scheduled (recurring) jobs
@@ -302,8 +305,6 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `issues.create` | | | `issues.create` |
| | `issues.update` | | | `issues.update` |
| | `issue.comments.create` | | | `issue.comments.create` |
| | `assets.write` |
| | `assets.read` |
| | `activity.log.write` | | | `activity.log.write` |
| | `metrics.write` | | | `metrics.write` |
| **Instance** | `instance.settings.register` | | **Instance** | `instance.settings.register` |
@@ -333,14 +334,15 @@ Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
## UI quick start ## UI quick start
```tsx ```tsx
import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui"; import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
export function DashboardWidget() { export function DashboardWidget() {
const { data } = usePluginData<{ status: string }>("health"); const { data } = usePluginData<{ status: string }>("health");
const ping = usePluginAction("ping"); const ping = usePluginAction("ping");
return ( return (
<div> <div style={{ display: "grid", gap: 8 }}>
<MetricCard label="Health" value={data?.status ?? "unknown"} /> <strong>Health</strong>
<div>{data?.status ?? "unknown"}</div>
<button onClick={() => void ping()}>Ping</button> <button onClick={() => void ping()}>Ping</button>
</div> </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 }`. Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
```tsx ```tsx
import { usePluginData, Spinner, StatusBadge } from "@paperclipai/plugin-sdk/ui"; import { usePluginData } from "@paperclipai/plugin-sdk/ui";
interface SyncStatus { interface SyncStatus {
lastSyncAt: string; lastSyncAt: string;
@@ -367,12 +369,12 @@ export function SyncStatusWidget({ context }: PluginWidgetProps) {
companyId: context.companyId, companyId: context.companyId,
}); });
if (loading) return <Spinner />; if (loading) return <div>Loading</div>;
if (error) return <StatusBadge label={error.message} status="error" />; if (error) return <div>Error: {error.message}</div>;
return ( return (
<div> <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>Synced {data!.syncedCount} items</p>
<p>Last sync: {data!.lastSyncAt}</p> <p>Last sync: {data!.lastSyncAt}</p>
<button onClick={refresh}>Refresh</button> <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. 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`. 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.
#### `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>
```
### Slot component props ### Slot component props
@@ -579,7 +490,7 @@ Example detail tab with entity context:
```tsx ```tsx
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; 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) { export function AgentMetricsTab({ context }: PluginDetailTabProps) {
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", { const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
@@ -587,13 +498,18 @@ export function AgentMetricsTab({ context }: PluginDetailTabProps) {
companyId: context.companyId, companyId: context.companyId,
}); });
if (loading) return <Spinner />; if (loading) return <div>Loading</div>;
if (!data) return <p>No metrics available.</p>; if (!data) return <p>No metrics available.</p>;
return ( return (
<KeyValueList <dl>
pairs={Object.entries(data).map(([label, value]) => ({ label, value }))} {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 ```tsx
import { useState } from "react"; import { useState } from "react";
import { import {
ErrorBoundary,
Spinner,
useHostContext, useHostContext,
usePluginAction, usePluginAction,
} from "@paperclipai/plugin-sdk/ui"; } from "@paperclipai/plugin-sdk/ui";
@@ -730,7 +644,7 @@ export function SyncToolbarButton() {
} }
return ( return (
<ErrorBoundary> <>
<button type="button" onClick={() => setOpen(true)}> <button type="button" onClick={() => setOpen(true)}>
Sync Sync
</button> </button>
@@ -757,13 +671,13 @@ export function SyncToolbarButton() {
Cancel Cancel
</button> </button>
<button type="button" onClick={() => void confirm()} disabled={submitting}> <button type="button" onClick={() => void confirm()} disabled={submitting}>
{submitting ? <Spinner size="sm" /> : "Run sync"} {submitting ? "Running…" : "Run sync"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) : null} ) : null}
</ErrorBoundary> </>
); );
} }
``` ```

View File

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

View File

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

View File

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

View File

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

View File

@@ -495,16 +495,6 @@ export interface WorkerToHostMethods {
result: string, 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
"activity.log": [ "activity.log": [
params: { params: {

View File

@@ -136,8 +136,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const state = new Map<string, unknown>(); const state = new Map<string, unknown>();
const entities = new Map<string, PluginEntityRecord>(); const entities = new Map<string, PluginEntityRecord>();
const entityExternalIndex = new Map<string, string>(); const entityExternalIndex = new Map<string, string>();
const assets = new Map<string, { contentType: string; data: Uint8Array }>();
const companies = new Map<string, Company>(); const companies = new Map<string, Company>();
const projects = new Map<string, Project>(); const projects = new Map<string, Project>();
const issues = new Map<string, Issue>(); const issues = new Map<string, Issue>();
@@ -207,19 +205,6 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
return `resolved:${secretRef}`; 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: { activity: {
async log(entry) { async log(entry) {
requireCapability(manifest, capabilitySet, "activity.log.write"); requireCapability(manifest, capabilitySet, "activity.log.write");

View File

@@ -452,34 +452,6 @@ export interface PluginSecretsClient {
resolve(secretRef: string): Promise<string>; 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. * Input for writing a plugin activity log entry.
* *
@@ -1069,9 +1041,6 @@ export interface PluginContext {
/** Resolve secret references. Requires `secrets.read-ref`. */ /** Resolve secret references. Requires `secrets.read-ref`. */
secrets: PluginSecretsClient; secrets: PluginSecretsClient;
/** Read and write assets. Requires `assets.read` / `assets.write`. */
assets: PluginAssetsClient;
/** Write activity log entries. Requires `activity.log.write`. */ /** Write activity log entries. Requires `activity.log.write`. */
activity: PluginActivityClient; activity: PluginActivityClient;

View File

@@ -29,9 +29,9 @@ import { getSdkUiRuntimeValue } from "./runtime.js";
* companyId: context.companyId, * companyId: context.companyId,
* }); * });
* *
* if (loading) return <Spinner />; * if (loading) return <div>Loading…</div>;
* if (error) return <div>Error: {error.message}</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 * @example
* ```tsx * ```tsx
* // Plugin UI bundle entry (dist/ui/index.tsx) * // Plugin UI bundle entry (dist/ui/index.tsx)
* import { * import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
* usePluginData,
* usePluginAction,
* useHostContext,
* MetricCard,
* StatusBadge,
* Spinner,
* } from "@paperclipai/plugin-sdk/ui";
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; * import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
* *
* export function DashboardWidget({ context }: PluginWidgetProps) { * export function DashboardWidget({ context }: PluginWidgetProps) {
@@ -28,12 +21,13 @@
* }); * });
* const resync = usePluginAction("resync"); * const resync = usePluginAction("resync");
* *
* if (loading) return <Spinner />; * if (loading) return <div>Loading…</div>;
* if (error) return <div>Error: {error.message}</div>; * if (error) return <div>Error: {error.message}</div>;
* *
* return ( * return (
* <div> * <div style={{ display: "grid", gap: 8 }}>
* <MetricCard label="Synced Issues" value={data!.syncedCount} /> * <strong>Synced Issues</strong>
* <div>{data!.syncedCount}</div>
* <button onClick={() => resync({ companyId: context.companyId })}> * <button onClick={() => resync({ companyId: context.companyId })}>
* Resync Now * Resync Now
* </button> * </button>
@@ -91,40 +85,3 @@ export type {
PluginCommentContextMenuItemProps, PluginCommentContextMenuItemProps,
PluginSettingsPageProps, PluginSettingsPageProps,
} from "./types.js"; } 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: { activity: {
async log(entry): Promise<void> { async log(entry): Promise<void> {
await callHost("activity.log", { await callHost("activity.log", {

View File

@@ -331,8 +331,6 @@ export const PLUGIN_CAPABILITIES = [
"agent.sessions.list", "agent.sessions.list",
"agent.sessions.send", "agent.sessions.send",
"agent.sessions.close", "agent.sessions.close",
"assets.write",
"assets.read",
"activity.log.write", "activity.log.write",
"metrics.write", "metrics.write",
// Plugin State // Plugin State

View File

@@ -61,15 +61,11 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"activity.get": ["activity.read"], "activity.get": ["activity.read"],
"costs.list": ["costs.read"], "costs.list": ["costs.read"],
"costs.get": ["costs.read"], "costs.get": ["costs.read"],
"assets.list": ["assets.read"],
"assets.get": ["assets.read"],
// Data write operations // Data write operations
"issues.create": ["issues.create"], "issues.create": ["issues.create"],
"issues.update": ["issues.update"], "issues.update": ["issues.update"],
"issue.comments.create": ["issue.comments.create"], "issue.comments.create": ["issue.comments.create"],
"assets.upload": ["assets.write"],
"assets.delete": ["assets.write"],
"activity.log": ["activity.log.write"], "activity.log": ["activity.log.write"],
"metrics.write": ["metrics.write"], "metrics.write": ["metrics.write"],

View File

@@ -582,17 +582,6 @@ export function buildHostServices(
}, },
}, },
assets: {
async upload(params) {
void params;
throw new Error("Plugin asset uploads are not supported in this build.");
},
async getUrl(params) {
void params;
throw new Error("Plugin asset URLs are not supported in this build.");
},
},
activity: { activity: {
async log(params) { async log(params) {
const companyId = ensureCompanyId(params.companyId); const companyId = ensureCompanyId(params.companyId);

View File

@@ -0,0 +1,101 @@
---
name: paperclip-create-plugin
description: >
Create new Paperclip plugins with the current alpha SDK/runtime. Use when
scaffolding a plugin package, adding a new example plugin, or updating plugin
authoring docs. Covers the supported worker/UI surface, route conventions,
scaffold flow, and verification steps.
---
# Create a Paperclip Plugin
Use this skill when the task is to create, scaffold, or document a Paperclip plugin.
## 1. Ground rules
Read these first when needed:
1. `doc/plugins/PLUGIN_AUTHORING_GUIDE.md`
2. `packages/plugins/sdk/README.md`
3. `doc/plugins/PLUGIN_SPEC.md` only for future-looking context
Current runtime assumptions:
- plugin workers are trusted code
- plugin UI is trusted same-origin host code
- worker APIs are capability-gated
- plugin UI is not sandboxed by manifest capabilities
- no host-provided shared plugin UI component kit yet
- `ctx.assets` is not supported in the current runtime
## 2. Preferred workflow
Use the scaffold package instead of hand-writing the boilerplate:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js <npm-package-name> --output <target-dir>
```
For a plugin that lives outside the Paperclip repo, pass `--sdk-path` and let the scaffold snapshot the local SDK/shared packages into `.paperclip-sdk/`:
```bash
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/plugin-name \
--output /absolute/path/to/plugin-repos \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
```
Recommended target inside this repo:
- `packages/plugins/examples/` for example plugins
- another `packages/plugins/<name>/` folder if it is becoming a real package
## 3. After scaffolding
Check and adjust:
- `src/manifest.ts`
- `src/worker.ts`
- `src/ui/index.tsx`
- `tests/plugin.spec.ts`
- `package.json`
Make sure the plugin:
- declares only supported capabilities
- does not use `ctx.assets`
- does not import host UI component stubs
- keeps UI self-contained
- uses `routePath` only on `page` slots
- is installed into Paperclip from an absolute local path during development
## 4. If the plugin should appear in the app
For bundled example/discoverable behavior, update the relevant host wiring:
- bundled example list in `server/src/routes/plugins.ts`
- any docs that list in-repo examples
Only do this if the user wants the plugin surfaced as a bundled example.
## 5. Verification
Always run:
```bash
pnpm --filter <plugin-package> typecheck
pnpm --filter <plugin-package> test
pnpm --filter <plugin-package> build
```
If you changed SDK/host/plugin runtime code too, also run broader repo checks as appropriate.
## 6. Documentation expectations
When authoring or updating plugin docs:
- distinguish current implementation from future spec ideas
- be explicit about the trusted-code model
- do not promise host UI components or asset APIs
- prefer npm-package deployment guidance over repo-local workflows for production

View File

@@ -8,6 +8,7 @@ import { queryKeys } from "@/lib/queryKeys";
import { PluginSlotMount } from "@/plugins/slots"; import { PluginSlotMount } from "@/plugins/slots";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { NotFoundPage } from "./NotFound";
/** /**
* Company-context plugin page. Renders a plugin's `page` slot at * Company-context plugin page. Renders a plugin's `page` slot at
@@ -25,12 +26,18 @@ export function PluginPage() {
}>(); }>();
const { companies, selectedCompanyId } = useCompany(); const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const routeCompany = useMemo(() => {
if (!routeCompanyPrefix) return null;
const requested = routeCompanyPrefix.toUpperCase();
return companies.find((c) => c.issuePrefix.toUpperCase() === requested) ?? null;
}, [companies, routeCompanyPrefix]);
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
const resolvedCompanyId = useMemo(() => { const resolvedCompanyId = useMemo(() => {
if (!routeCompanyPrefix) return selectedCompanyId ?? null; if (routeCompany) return routeCompany.id;
const requested = routeCompanyPrefix.toUpperCase(); if (routeCompanyPrefix) return null;
return companies.find((c) => c.issuePrefix.toUpperCase() === requested)?.id ?? selectedCompanyId ?? null; return selectedCompanyId ?? null;
}, [companies, routeCompanyPrefix, selectedCompanyId]); }, [routeCompany, routeCompanyPrefix, selectedCompanyId]);
const companyPrefix = useMemo( const companyPrefix = useMemo(
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null), () => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
@@ -92,6 +99,9 @@ export function PluginPage() {
}, [pageSlot, companyPrefix, setBreadcrumbs]); }, [pageSlot, companyPrefix, setBreadcrumbs]);
if (!resolvedCompanyId) { if (!resolvedCompanyId) {
if (hasInvalidCompanyPrefix) {
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">Select a company to view this page.</p> <p className="text-sm text-muted-foreground">Select a company to view this page.</p>
@@ -117,6 +127,9 @@ export function PluginPage() {
} }
if (!pageSlot) { if (!pageSlot) {
if (pluginRoutePath) {
return <NotFoundPage scope="board" />;
}
// No page slot: redirect to plugin settings where plugin info is always shown // No page slot: redirect to plugin settings where plugin info is always shown
const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins"; const settingsPath = pluginId ? `/instance/settings/plugins/${pluginId}` : "/instance/settings/plugins";
return <Navigate to={settingsPath} replace />; return <Navigate to={settingsPath} replace />;

View File

@@ -12,7 +12,6 @@
* @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation * @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation
*/ */
import type { ReactNode } from "react";
import { import {
usePluginData, usePluginData,
usePluginAction, usePluginAction,
@@ -60,61 +59,11 @@ export function initPluginBridge(
react, react,
reactDom, reactDom,
sdkUi: { sdkUi: {
// Bridge hooks
usePluginData, usePluginData,
usePluginAction, usePluginAction,
useHostContext, useHostContext,
usePluginStream, usePluginStream,
usePluginToast, usePluginToast,
// Placeholder shared UI components — plugins that use these will get
// functional stubs. Full implementations matching the host's design
// system can be added later.
MetricCard: createStubComponent("MetricCard"),
StatusBadge: createStubComponent("StatusBadge"),
DataTable: createStubComponent("DataTable"),
TimeseriesChart: createStubComponent("TimeseriesChart"),
MarkdownBlock: createStubComponent("MarkdownBlock"),
KeyValueList: createStubComponent("KeyValueList"),
ActionBar: createStubComponent("ActionBar"),
LogView: createStubComponent("LogView"),
JsonTree: createStubComponent("JsonTree"),
Spinner: createStubComponent("Spinner"),
ErrorBoundary: createPassthroughComponent("ErrorBoundary"),
}, },
}; };
} }
// ---------------------------------------------------------------------------
// Stub component helpers
// ---------------------------------------------------------------------------
function createStubComponent(name: string): unknown {
const fn = (props: Record<string, unknown>) => {
// Import React from the registry to avoid import issues
const React = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
if (!React) return null;
return React.createElement("div", {
"data-plugin-component": name,
style: {
padding: "8px",
border: "1px dashed #666",
borderRadius: "4px",
fontSize: "12px",
color: "#888",
},
}, `[${name}]`);
};
Object.defineProperty(fn, "name", { value: name });
return fn;
}
function createPassthroughComponent(name: string): unknown {
const fn = (props: { children?: ReactNode }) => {
const ReactLib = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
if (!ReactLib) return null;
return ReactLib.createElement(ReactLib.Fragment, null, props.children);
};
Object.defineProperty(fn, "name", { value: name });
return fn;
}

View File

@@ -257,14 +257,8 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
case "sdk-ui": case "sdk-ui":
source = ` source = `
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {}; const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast, const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast } = SDK;
MetricCard, StatusBadge, DataTable, TimeseriesChart, export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
Spinner, ErrorBoundary } = SDK;
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast,
MetricCard, StatusBadge, DataTable, TimeseriesChart,
MarkdownBlock, KeyValueList, ActionBar, LogView, JsonTree,
Spinner, ErrorBoundary };
`; `;
break; break;
} }
@@ -294,8 +288,6 @@ function rewriteBareSpecifiers(source: string): string {
"'@paperclipai/plugin-sdk/ui'": `'${getShimBlobUrl("sdk-ui")}'`, "'@paperclipai/plugin-sdk/ui'": `'${getShimBlobUrl("sdk-ui")}'`,
'"@paperclipai/plugin-sdk/ui/hooks"': `"${getShimBlobUrl("sdk-ui")}"`, '"@paperclipai/plugin-sdk/ui/hooks"': `"${getShimBlobUrl("sdk-ui")}"`,
"'@paperclipai/plugin-sdk/ui/hooks'": `'${getShimBlobUrl("sdk-ui")}'`, "'@paperclipai/plugin-sdk/ui/hooks'": `'${getShimBlobUrl("sdk-ui")}'`,
'"@paperclipai/plugin-sdk/ui/components"': `"${getShimBlobUrl("sdk-ui")}"`,
"'@paperclipai/plugin-sdk/ui/components'": `'${getShimBlobUrl("sdk-ui")}'`,
'"react/jsx-runtime"': `"${getShimBlobUrl("react/jsx-runtime")}"`, '"react/jsx-runtime"': `"${getShimBlobUrl("react/jsx-runtime")}"`,
"'react/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`, "'react/jsx-runtime'": `'${getShimBlobUrl("react/jsx-runtime")}'`,
'"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`, '"react-dom/client"': `"${getShimBlobUrl("react-dom/client")}"`,