From 80cdbdbd47ceb6fcb7f2ea1d784063cc9e51698d Mon Sep 17 00:00:00 2001
From: Dotta
Date: Fri, 13 Mar 2026 16:22:34 -0500
Subject: [PATCH] Add plugin framework and settings UI
---
doc/plugins/PLUGIN_SPEC.md | 24 +
.../db/src/migrations/0028_plugin_tables.sql | 177 ++
packages/db/src/migrations/meta/_journal.json | 9 +-
packages/db/src/schema/index.ts | 8 +
.../db/src/schema/plugin_company_settings.ts | 41 +
packages/db/src/schema/plugin_config.ts | 30 +
packages/db/src/schema/plugin_entities.ts | 54 +
packages/db/src/schema/plugin_jobs.ts | 102 +
packages/db/src/schema/plugin_logs.ts | 43 +
packages/db/src/schema/plugin_state.ts | 90 +
packages/db/src/schema/plugin_webhooks.ts | 65 +
packages/db/src/schema/plugins.ts | 45 +
.../plugins/create-paperclip-plugin/README.md | 38 +
.../create-paperclip-plugin/package.json | 40 +
.../create-paperclip-plugin/src/index.ts | 398 +++
.../create-paperclip-plugin/tsconfig.json | 9 +
.../plugin-file-browser-example/README.md | 62 +
.../plugin-file-browser-example/package.json | 42 +
.../scripts/build-ui.mjs | 24 +
.../plugin-file-browser-example/src/index.ts | 2 +
.../src/manifest.ts | 85 +
.../src/ui/index.tsx | 815 ++++++
.../plugin-file-browser-example/src/worker.ts | 226 ++
.../plugin-file-browser-example/tsconfig.json | 10 +
.../plugin-hello-world-example/README.md | 38 +
.../plugin-hello-world-example/package.json | 35 +
.../plugin-hello-world-example/src/index.ts | 2 +
.../src/manifest.ts | 39 +
.../src/ui/index.tsx | 17 +
.../plugin-hello-world-example/src/worker.ts | 27 +
.../plugin-hello-world-example/tsconfig.json | 10 +
packages/plugins/sdk/README.md | 959 +++++++
packages/plugins/sdk/package.json | 124 +
packages/plugins/sdk/src/bundlers.ts | 161 ++
packages/plugins/sdk/src/define-plugin.ts | 255 ++
packages/plugins/sdk/src/dev-cli.ts | 54 +
packages/plugins/sdk/src/dev-server.ts | 228 ++
.../plugins/sdk/src/host-client-factory.ts | 563 ++++
packages/plugins/sdk/src/index.ts | 287 ++
packages/plugins/sdk/src/protocol.ts | 1038 +++++++
packages/plugins/sdk/src/testing.ts | 720 +++++
packages/plugins/sdk/src/types.ts | 1116 ++++++++
packages/plugins/sdk/src/ui/components.ts | 310 +++
packages/plugins/sdk/src/ui/hooks.ts | 153 ++
packages/plugins/sdk/src/ui/index.ts | 125 +
packages/plugins/sdk/src/ui/runtime.ts | 51 +
packages/plugins/sdk/src/ui/types.ts | 358 +++
packages/plugins/sdk/src/worker-rpc-host.ts | 1221 +++++++++
packages/plugins/sdk/tsconfig.json | 9 +
packages/shared/src/constants.ts | 312 ++-
packages/shared/src/index.ts | 94 +
packages/shared/src/types/index.ts | 24 +
packages/shared/src/types/plugin.ts | 545 ++++
packages/shared/src/validators/index.ts | 42 +
packages/shared/src/validators/plugin.ts | 694 +++++
pnpm-workspace.yaml | 2 +
scripts/ensure-plugin-build-deps.mjs | 46 +
server/package.json | 5 +-
.../__tests__/plugin-worker-manager.test.ts | 43 +
server/src/app.ts | 125 +-
server/src/routes/plugin-ui-static.ts | 496 ++++
server/src/routes/plugins.ts | 2417 +++++++++++++++++
server/src/services/cron.ts | 373 +++
server/src/services/live-events.ts | 14 +
.../services/plugin-capability-validator.ts | 451 +++
.../src/services/plugin-config-validator.ts | 50 +
server/src/services/plugin-dev-watcher.ts | 189 ++
server/src/services/plugin-event-bus.ts | 515 ++++
.../services/plugin-host-service-cleanup.ts | 59 +
server/src/services/plugin-host-services.ts | 1077 ++++++++
server/src/services/plugin-job-coordinator.ts | 260 ++
server/src/services/plugin-job-scheduler.ts | 752 +++++
server/src/services/plugin-job-store.ts | 465 ++++
server/src/services/plugin-lifecycle.ts | 807 ++++++
server/src/services/plugin-loader.ts | 1852 +++++++++++++
server/src/services/plugin-log-retention.ts | 86 +
.../src/services/plugin-manifest-validator.ts | 163 ++
server/src/services/plugin-registry.ts | 963 +++++++
server/src/services/plugin-runtime-sandbox.ts | 221 ++
server/src/services/plugin-secrets-handler.ts | 367 +++
server/src/services/plugin-state-store.ts | 237 ++
server/src/services/plugin-stream-bus.ts | 81 +
server/src/services/plugin-tool-dispatcher.ts | 448 +++
server/src/services/plugin-tool-registry.ts | 449 +++
server/src/services/plugin-worker-manager.ts | 1342 +++++++++
ui/src/App.tsx | 13 +-
ui/src/api/client.ts | 2 +
ui/src/api/plugins.ts | 469 ++++
ui/src/components/InstanceSidebar.tsx | 5 +-
ui/src/components/JsonSchemaForm.tsx | 1048 +++++++
ui/src/components/Layout.tsx | 54 +-
ui/src/components/SidebarProjects.tsx | 75 +-
ui/src/lib/queryKeys.ts | 16 +
ui/src/main.tsx | 5 +
ui/src/pages/Dashboard.tsx | 8 +
ui/src/pages/PluginManager.tsx | 512 ++++
ui/src/pages/PluginPage.tsx | 113 +
ui/src/pages/PluginSettings.tsx | 836 ++++++
ui/src/pages/ProjectDetail.tsx | 66 +-
ui/src/plugins/bridge-init.ts | 116 +
ui/src/plugins/bridge.ts | 361 +++
ui/src/plugins/launchers.tsx | 829 ++++++
ui/src/plugins/slots.tsx | 862 ++++++
103 files changed, 31760 insertions(+), 35 deletions(-)
create mode 100644 packages/db/src/migrations/0028_plugin_tables.sql
create mode 100644 packages/db/src/schema/plugin_company_settings.ts
create mode 100644 packages/db/src/schema/plugin_config.ts
create mode 100644 packages/db/src/schema/plugin_entities.ts
create mode 100644 packages/db/src/schema/plugin_jobs.ts
create mode 100644 packages/db/src/schema/plugin_logs.ts
create mode 100644 packages/db/src/schema/plugin_state.ts
create mode 100644 packages/db/src/schema/plugin_webhooks.ts
create mode 100644 packages/db/src/schema/plugins.ts
create mode 100644 packages/plugins/create-paperclip-plugin/README.md
create mode 100644 packages/plugins/create-paperclip-plugin/package.json
create mode 100644 packages/plugins/create-paperclip-plugin/src/index.ts
create mode 100644 packages/plugins/create-paperclip-plugin/tsconfig.json
create mode 100644 packages/plugins/examples/plugin-file-browser-example/README.md
create mode 100644 packages/plugins/examples/plugin-file-browser-example/package.json
create mode 100644 packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs
create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/index.ts
create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/manifest.ts
create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx
create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/worker.ts
create mode 100644 packages/plugins/examples/plugin-file-browser-example/tsconfig.json
create mode 100644 packages/plugins/examples/plugin-hello-world-example/README.md
create mode 100644 packages/plugins/examples/plugin-hello-world-example/package.json
create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/index.ts
create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/manifest.ts
create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx
create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/worker.ts
create mode 100644 packages/plugins/examples/plugin-hello-world-example/tsconfig.json
create mode 100644 packages/plugins/sdk/README.md
create mode 100644 packages/plugins/sdk/package.json
create mode 100644 packages/plugins/sdk/src/bundlers.ts
create mode 100644 packages/plugins/sdk/src/define-plugin.ts
create mode 100644 packages/plugins/sdk/src/dev-cli.ts
create mode 100644 packages/plugins/sdk/src/dev-server.ts
create mode 100644 packages/plugins/sdk/src/host-client-factory.ts
create mode 100644 packages/plugins/sdk/src/index.ts
create mode 100644 packages/plugins/sdk/src/protocol.ts
create mode 100644 packages/plugins/sdk/src/testing.ts
create mode 100644 packages/plugins/sdk/src/types.ts
create mode 100644 packages/plugins/sdk/src/ui/components.ts
create mode 100644 packages/plugins/sdk/src/ui/hooks.ts
create mode 100644 packages/plugins/sdk/src/ui/index.ts
create mode 100644 packages/plugins/sdk/src/ui/runtime.ts
create mode 100644 packages/plugins/sdk/src/ui/types.ts
create mode 100644 packages/plugins/sdk/src/worker-rpc-host.ts
create mode 100644 packages/plugins/sdk/tsconfig.json
create mode 100644 packages/shared/src/types/plugin.ts
create mode 100644 packages/shared/src/validators/plugin.ts
create mode 100644 scripts/ensure-plugin-build-deps.mjs
create mode 100644 server/src/__tests__/plugin-worker-manager.test.ts
create mode 100644 server/src/routes/plugin-ui-static.ts
create mode 100644 server/src/routes/plugins.ts
create mode 100644 server/src/services/cron.ts
create mode 100644 server/src/services/plugin-capability-validator.ts
create mode 100644 server/src/services/plugin-config-validator.ts
create mode 100644 server/src/services/plugin-dev-watcher.ts
create mode 100644 server/src/services/plugin-event-bus.ts
create mode 100644 server/src/services/plugin-host-service-cleanup.ts
create mode 100644 server/src/services/plugin-host-services.ts
create mode 100644 server/src/services/plugin-job-coordinator.ts
create mode 100644 server/src/services/plugin-job-scheduler.ts
create mode 100644 server/src/services/plugin-job-store.ts
create mode 100644 server/src/services/plugin-lifecycle.ts
create mode 100644 server/src/services/plugin-loader.ts
create mode 100644 server/src/services/plugin-log-retention.ts
create mode 100644 server/src/services/plugin-manifest-validator.ts
create mode 100644 server/src/services/plugin-registry.ts
create mode 100644 server/src/services/plugin-runtime-sandbox.ts
create mode 100644 server/src/services/plugin-secrets-handler.ts
create mode 100644 server/src/services/plugin-state-store.ts
create mode 100644 server/src/services/plugin-stream-bus.ts
create mode 100644 server/src/services/plugin-tool-dispatcher.ts
create mode 100644 server/src/services/plugin-tool-registry.ts
create mode 100644 server/src/services/plugin-worker-manager.ts
create mode 100644 ui/src/api/plugins.ts
create mode 100644 ui/src/components/JsonSchemaForm.tsx
create mode 100644 ui/src/pages/PluginManager.tsx
create mode 100644 ui/src/pages/PluginPage.tsx
create mode 100644 ui/src/pages/PluginSettings.tsx
create mode 100644 ui/src/plugins/bridge-init.ts
create mode 100644 ui/src/plugins/bridge.ts
create mode 100644 ui/src/plugins/launchers.tsx
create mode 100644 ui/src/plugins/slots.tsx
diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md
index 896f5115..65fabac0 100644
--- a/doc/plugins/PLUGIN_SPEC.md
+++ b/doc/plugins/PLUGIN_SPEC.md
@@ -8,6 +8,26 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea
This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md).
It is the full target architecture for the plugin system that should follow V1.
+## Current implementation caveats
+
+The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec.
+
+Today, the practical deployment model is:
+
+- single-tenant
+- self-hosted
+- single-node or otherwise filesystem-persistent
+
+Current limitations to keep in mind:
+
+- 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.
+- 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.
+- 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.
+
+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.
+
## 1. Scope
This spec covers:
@@ -212,6 +232,8 @@ Suggested layout:
The package install directory and the plugin data directory are separate.
+This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work.
+
## 8.2 Operator Commands
Paperclip should add CLI commands:
@@ -237,6 +259,8 @@ The install process is:
7. Start plugin worker and run health/validation.
8. Mark plugin `ready` or `error`.
+For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added.
+
## 9. Load Order And Precedence
Load order must be deterministic.
diff --git a/packages/db/src/migrations/0028_plugin_tables.sql b/packages/db/src/migrations/0028_plugin_tables.sql
new file mode 100644
index 00000000..8ee0d937
--- /dev/null
+++ b/packages/db/src/migrations/0028_plugin_tables.sql
@@ -0,0 +1,177 @@
+-- Rollback:
+-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
+-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
+-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
+-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
+-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
+-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
+-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
+-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
+-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
+-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
+-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
+-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
+-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
+-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
+-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
+-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
+-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
+-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
+-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
+-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
+-- DROP INDEX IF EXISTS "plugins_status_idx";
+-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
+-- DROP TABLE IF EXISTS "plugin_logs";
+-- DROP TABLE IF EXISTS "plugin_company_settings";
+-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
+-- DROP TABLE IF EXISTS "plugin_job_runs";
+-- DROP TABLE IF EXISTS "plugin_jobs";
+-- DROP TABLE IF EXISTS "plugin_entities";
+-- DROP TABLE IF EXISTS "plugin_state";
+-- DROP TABLE IF EXISTS "plugin_config";
+-- DROP TABLE IF EXISTS "plugins";
+
+CREATE TABLE "plugins" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "plugin_key" text NOT NULL,
+ "package_name" text NOT NULL,
+ "package_path" text,
+ "version" text NOT NULL,
+ "api_version" integer DEFAULT 1 NOT NULL,
+ "categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "manifest_json" jsonb NOT NULL,
+ "status" text DEFAULT 'installed' NOT NULL,
+ "install_order" integer,
+ "last_error" text,
+ "installed_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_config" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "last_error" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_state" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "scope_kind" text NOT NULL,
+ "scope_id" text,
+ "namespace" text DEFAULT 'default' NOT NULL,
+ "state_key" text NOT NULL,
+ "value_json" jsonb NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_entities" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "entity_type" text NOT NULL,
+ "scope_kind" text NOT NULL,
+ "scope_id" text,
+ "external_id" text,
+ "title" text,
+ "status" text,
+ "data" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_jobs" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "job_key" text NOT NULL,
+ "schedule" text NOT NULL,
+ "status" text DEFAULT 'active' NOT NULL,
+ "last_run_at" timestamp with time zone,
+ "next_run_at" timestamp with time zone,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_job_runs" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "job_id" uuid NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "trigger" text NOT NULL,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "duration_ms" integer,
+ "error" text,
+ "logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "started_at" timestamp with time zone,
+ "finished_at" timestamp with time zone,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_webhook_deliveries" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "webhook_key" text NOT NULL,
+ "external_id" text,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "duration_ms" integer,
+ "error" text,
+ "payload" jsonb NOT NULL,
+ "headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "started_at" timestamp with time zone,
+ "finished_at" timestamp with time zone,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_company_settings" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "plugin_id" uuid NOT NULL,
+ "settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "last_error" text,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "plugin_logs" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ "plugin_id" uuid NOT NULL,
+ "level" text NOT NULL DEFAULT 'info',
+ "message" text NOT NULL,
+ "meta" jsonb,
+ "created_at" timestamp with time zone NOT NULL DEFAULT now()
+);
+--> statement-breakpoint
+ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
+CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
+CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
+CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
+CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
+CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
+CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
+CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
+CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
+CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
+CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
+CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
+CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
+CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
+CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
+CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
+CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
+CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 80a1dfbd..63c18087 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -197,6 +197,13 @@
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
+ },
+ {
+ "idx": 28,
+ "version": "7",
+ "when": 1773417600000,
+ "tag": "0028_plugin_tables",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index 3416ea9a..25904f70 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -32,3 +32,11 @@ export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
+export { plugins } from "./plugins.js";
+export { pluginConfig } from "./plugin_config.js";
+export { pluginCompanySettings } from "./plugin_company_settings.js";
+export { pluginState } from "./plugin_state.js";
+export { pluginEntities } from "./plugin_entities.js";
+export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
+export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
+export { pluginLogs } from "./plugin_logs.js";
diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts
new file mode 100644
index 00000000..87d4b4af
--- /dev/null
+++ b/packages/db/src/schema/plugin_company_settings.ts
@@ -0,0 +1,41 @@
+import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
+import { companies } from "./companies.js";
+import { plugins } from "./plugins.js";
+
+/**
+ * `plugin_company_settings` table — stores operator-managed plugin settings
+ * scoped to a specific company.
+ *
+ * This is distinct from `plugin_config`, which stores instance-wide plugin
+ * configuration. Each company can have at most one settings row per plugin.
+ *
+ * Rows represent explicit overrides from the default company behavior:
+ * - no row => plugin is enabled for the company by default
+ * - row with `enabled = false` => plugin is disabled for that company
+ * - row with `enabled = true` => plugin remains enabled and stores company settings
+ */
+export const pluginCompanySettings = pgTable(
+ "plugin_company_settings",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ companyId: uuid("company_id")
+ .notNull()
+ .references(() => companies.id, { onDelete: "cascade" }),
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ enabled: boolean("enabled").notNull().default(true),
+ settingsJson: jsonb("settings_json").$type>().notNull().default({}),
+ lastError: text("last_error"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
+ pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
+ companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
+ table.companyId,
+ table.pluginId,
+ ),
+ }),
+);
diff --git a/packages/db/src/schema/plugin_config.ts b/packages/db/src/schema/plugin_config.ts
new file mode 100644
index 00000000..24407b97
--- /dev/null
+++ b/packages/db/src/schema/plugin_config.ts
@@ -0,0 +1,30 @@
+import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
+import { plugins } from "./plugins.js";
+
+/**
+ * `plugin_config` table — stores operator-provided instance configuration
+ * for each plugin (one row per plugin, enforced by a unique index on
+ * `plugin_id`).
+ *
+ * The `config_json` column holds the values that the operator enters in the
+ * plugin settings UI. These values are validated at runtime against the
+ * plugin's `instanceConfigSchema` from the manifest.
+ *
+ * @see PLUGIN_SPEC.md §21.3
+ */
+export const pluginConfig = pgTable(
+ "plugin_config",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ configJson: jsonb("config_json").$type>().notNull().default({}),
+ lastError: text("last_error"),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
+ }),
+);
diff --git a/packages/db/src/schema/plugin_entities.ts b/packages/db/src/schema/plugin_entities.ts
new file mode 100644
index 00000000..5f732304
--- /dev/null
+++ b/packages/db/src/schema/plugin_entities.ts
@@ -0,0 +1,54 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ timestamp,
+ jsonb,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { plugins } from "./plugins.js";
+import type { PluginStateScopeKind } from "@paperclipai/shared";
+
+/**
+ * `plugin_entities` table — persistent high-level mapping between Paperclip
+ * objects and external plugin-defined entities.
+ *
+ * This table is used by plugins (e.g. `linear`, `github`) to store pointers
+ * to their respective external IDs for projects, issues, etc. and to store
+ * their custom data.
+ *
+ * Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
+ * is intended for structured object mappings that the host can understand
+ * and query for cross-plugin UI integration.
+ *
+ * @see PLUGIN_SPEC.md §21.3
+ */
+export const pluginEntities = pgTable(
+ "plugin_entities",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ entityType: text("entity_type").notNull(),
+ scopeKind: text("scope_kind").$type().notNull(),
+ scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
+ externalId: text("external_id"), // ID in the external system
+ title: text("title"),
+ status: text("status"),
+ data: jsonb("data").$type>().notNull().default({}),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
+ typeIdx: index("plugin_entities_type_idx").on(table.entityType),
+ scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
+ externalIdx: uniqueIndex("plugin_entities_external_idx").on(
+ table.pluginId,
+ table.entityType,
+ table.externalId,
+ ),
+ }),
+);
diff --git a/packages/db/src/schema/plugin_jobs.ts b/packages/db/src/schema/plugin_jobs.ts
new file mode 100644
index 00000000..fec0d0c4
--- /dev/null
+++ b/packages/db/src/schema/plugin_jobs.ts
@@ -0,0 +1,102 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ timestamp,
+ jsonb,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { plugins } from "./plugins.js";
+import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
+
+/**
+ * `plugin_jobs` table — registration and runtime configuration for
+ * scheduled jobs declared by plugins in their manifests.
+ *
+ * Each row represents one scheduled job entry for a plugin. The
+ * `job_key` matches the key declared in the manifest's `jobs` array.
+ * The `schedule` column stores the cron expression or interval string
+ * used by the job scheduler to decide when to fire the job.
+ *
+ * Status values:
+ * - `active` — job is enabled and will run on schedule
+ * - `paused` — job is temporarily disabled by the operator
+ * - `error` — job has been disabled due to repeated failures
+ *
+ * @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
+ */
+export const pluginJobs = pgTable(
+ "plugin_jobs",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ /** FK to the owning plugin. Cascades on delete. */
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ /** Identifier matching the key in the plugin manifest's `jobs` array. */
+ jobKey: text("job_key").notNull(),
+ /** Cron expression (e.g. `"0 * * * *"`) or interval string. */
+ schedule: text("schedule").notNull(),
+ /** Current scheduling state. */
+ status: text("status").$type().notNull().default("active"),
+ /** Timestamp of the most recent successful execution. */
+ lastRunAt: timestamp("last_run_at", { withTimezone: true }),
+ /** Pre-computed timestamp of the next scheduled execution. */
+ nextRunAt: timestamp("next_run_at", { withTimezone: true }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
+ nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
+ uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
+ }),
+);
+
+/**
+ * `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
+ *
+ * Each row is created when a job run begins and updated when it completes.
+ * Rows are never modified after `status` reaches a terminal value
+ * (`succeeded` | `failed` | `cancelled`).
+ *
+ * Trigger values:
+ * - `scheduled` — fired automatically by the cron/interval scheduler
+ * - `manual` — triggered by an operator via the admin UI or API
+ *
+ * @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
+ */
+export const pluginJobRuns = pgTable(
+ "plugin_job_runs",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ /** FK to the parent job definition. Cascades on delete. */
+ jobId: uuid("job_id")
+ .notNull()
+ .references(() => pluginJobs.id, { onDelete: "cascade" }),
+ /** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ /** What caused this run to start (`"scheduled"` or `"manual"`). */
+ trigger: text("trigger").$type().notNull(),
+ /** Current lifecycle state of this run. */
+ status: text("status").$type().notNull().default("pending"),
+ /** Wall-clock duration in milliseconds. Null until the run finishes. */
+ durationMs: integer("duration_ms"),
+ /** Error message if `status === "failed"`. */
+ error: text("error"),
+ /** Ordered list of log lines emitted during this run. */
+ logs: jsonb("logs").$type().notNull().default([]),
+ startedAt: timestamp("started_at", { withTimezone: true }),
+ finishedAt: timestamp("finished_at", { withTimezone: true }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
+ pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
+ statusIdx: index("plugin_job_runs_status_idx").on(table.status),
+ }),
+);
diff --git a/packages/db/src/schema/plugin_logs.ts b/packages/db/src/schema/plugin_logs.ts
new file mode 100644
index 00000000..d32908f1
--- /dev/null
+++ b/packages/db/src/schema/plugin_logs.ts
@@ -0,0 +1,43 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ timestamp,
+ jsonb,
+ index,
+} from "drizzle-orm/pg-core";
+import { plugins } from "./plugins.js";
+
+/**
+ * `plugin_logs` table — structured log storage for plugin workers.
+ *
+ * Each row stores a single log entry emitted by a plugin worker via
+ * `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
+ * time range to support the operator logs panel and debugging workflows.
+ *
+ * Rows are inserted by the host when handling `log` notifications from
+ * the worker process. A capped retention policy can be applied via
+ * periodic cleanup (e.g. delete rows older than 7 days).
+ *
+ * @see PLUGIN_SPEC.md §26 — Observability
+ */
+export const pluginLogs = pgTable(
+ "plugin_logs",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ level: text("level").notNull().default("info"),
+ message: text("message").notNull(),
+ meta: jsonb("meta").$type>(),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
+ table.pluginId,
+ table.createdAt,
+ ),
+ levelIdx: index("plugin_logs_level_idx").on(table.level),
+ }),
+);
diff --git a/packages/db/src/schema/plugin_state.ts b/packages/db/src/schema/plugin_state.ts
new file mode 100644
index 00000000..600797fa
--- /dev/null
+++ b/packages/db/src/schema/plugin_state.ts
@@ -0,0 +1,90 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ timestamp,
+ jsonb,
+ index,
+ unique,
+} from "drizzle-orm/pg-core";
+import type { PluginStateScopeKind } from "@paperclipai/shared";
+import { plugins } from "./plugins.js";
+
+/**
+ * `plugin_state` table — scoped key-value storage for plugin workers.
+ *
+ * Each row stores a single JSON value identified by
+ * `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
+ * this table through `ctx.state.get()`, `ctx.state.set()`, and
+ * `ctx.state.delete()` in the SDK.
+ *
+ * Scope kinds determine the granularity of isolation:
+ * - `instance` — one value shared across the whole Paperclip instance
+ * - `company` — one value per company
+ * - `project` — one value per project
+ * - `project_workspace` — one value per project workspace
+ * - `agent` — one value per agent
+ * - `issue` — one value per issue
+ * - `goal` — one value per goal
+ * - `run` — one value per agent run
+ *
+ * The `namespace` column defaults to `"default"` and can be used to
+ * logically group keys without polluting the root namespace.
+ *
+ * @see PLUGIN_SPEC.md §21.3 — `plugin_state`
+ */
+export const pluginState = pgTable(
+ "plugin_state",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ /** FK to the owning plugin. Cascades on delete. */
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ /** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
+ scopeKind: text("scope_kind").$type().notNull(),
+ /**
+ * UUID or text identifier for the scoped object.
+ * Null for `instance` scope (which has no associated entity).
+ */
+ scopeId: text("scope_id"),
+ /**
+ * Sub-namespace to avoid key collisions within a scope.
+ * Defaults to `"default"` if the plugin does not specify one.
+ */
+ namespace: text("namespace").notNull().default("default"),
+ /** The key identifying this state entry within the namespace. */
+ stateKey: text("state_key").notNull(),
+ /** JSON-serializable value stored by the plugin. */
+ valueJson: jsonb("value_json").notNull(),
+ /** Timestamp of the most recent write. */
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ /**
+ * Unique constraint enforces that there is at most one value per
+ * (plugin, scope kind, scope id, namespace, key) tuple.
+ *
+ * `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
+ * (used by `instance` scope) are treated as equal by PostgreSQL rather
+ * than as distinct nulls — otherwise the upsert target in `set()` would
+ * fail to match existing rows and create duplicates.
+ *
+ * Requires PostgreSQL 15+.
+ */
+ uniqueEntry: unique("plugin_state_unique_entry_idx")
+ .on(
+ table.pluginId,
+ table.scopeKind,
+ table.scopeId,
+ table.namespace,
+ table.stateKey,
+ )
+ .nullsNotDistinct(),
+ /** Speed up lookups by plugin + scope kind (most common access pattern). */
+ pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
+ table.pluginId,
+ table.scopeKind,
+ ),
+ }),
+);
diff --git a/packages/db/src/schema/plugin_webhooks.ts b/packages/db/src/schema/plugin_webhooks.ts
new file mode 100644
index 00000000..0580e970
--- /dev/null
+++ b/packages/db/src/schema/plugin_webhooks.ts
@@ -0,0 +1,65 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ timestamp,
+ jsonb,
+ index,
+} from "drizzle-orm/pg-core";
+import { plugins } from "./plugins.js";
+import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
+
+/**
+ * `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
+ *
+ * When an external system sends an HTTP POST to a plugin's registered webhook
+ * endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
+ * creates a row in this table before dispatching the payload to the plugin
+ * worker. This provides an auditable log of every delivery attempt.
+ *
+ * The `webhook_key` matches the key declared in the plugin manifest's
+ * `webhooks` array. `external_id` is an optional identifier supplied by the
+ * remote system (e.g. a GitHub delivery GUID) that can be used to detect
+ * and reject duplicate deliveries.
+ *
+ * Status values:
+ * - `pending` — received but not yet dispatched to the worker
+ * - `processing` — currently being handled by the plugin worker
+ * - `succeeded` — worker processed the payload successfully
+ * - `failed` — worker returned an error or timed out
+ *
+ * @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
+ */
+export const pluginWebhookDeliveries = pgTable(
+ "plugin_webhook_deliveries",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ /** FK to the owning plugin. Cascades on delete. */
+ pluginId: uuid("plugin_id")
+ .notNull()
+ .references(() => plugins.id, { onDelete: "cascade" }),
+ /** Identifier matching the key in the plugin manifest's `webhooks` array. */
+ webhookKey: text("webhook_key").notNull(),
+ /** Optional de-duplication ID provided by the external system. */
+ externalId: text("external_id"),
+ /** Current delivery state. */
+ status: text("status").$type().notNull().default("pending"),
+ /** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
+ durationMs: integer("duration_ms"),
+ /** Error message if `status === "failed"`. */
+ error: text("error"),
+ /** Raw JSON body of the inbound HTTP request. */
+ payload: jsonb("payload").$type>().notNull(),
+ /** Relevant HTTP headers from the inbound request (e.g. signature headers). */
+ headers: jsonb("headers").$type>().notNull().default({}),
+ startedAt: timestamp("started_at", { withTimezone: true }),
+ finishedAt: timestamp("finished_at", { withTimezone: true }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
+ statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
+ keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
+ }),
+);
diff --git a/packages/db/src/schema/plugins.ts b/packages/db/src/schema/plugins.ts
new file mode 100644
index 00000000..948e5d60
--- /dev/null
+++ b/packages/db/src/schema/plugins.ts
@@ -0,0 +1,45 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ timestamp,
+ jsonb,
+ index,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
+
+/**
+ * `plugins` table — stores one row per installed plugin.
+ *
+ * Each plugin is uniquely identified by `plugin_key` (derived from
+ * the manifest `id`). The full manifest is persisted as JSONB in
+ * `manifest_json` so the host can reconstruct capability and UI
+ * slot information without loading the plugin package.
+ *
+ * @see PLUGIN_SPEC.md §21.3
+ */
+export const plugins = pgTable(
+ "plugins",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ pluginKey: text("plugin_key").notNull(),
+ packageName: text("package_name").notNull(),
+ version: text("version").notNull(),
+ apiVersion: integer("api_version").notNull().default(1),
+ categories: jsonb("categories").$type().notNull().default([]),
+ manifestJson: jsonb("manifest_json").$type().notNull(),
+ status: text("status").$type().notNull().default("installed"),
+ installOrder: integer("install_order"),
+ /** Resolved package path for local-path installs; used to find worker entrypoint. */
+ packagePath: text("package_path"),
+ lastError: text("last_error"),
+ installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => ({
+ pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
+ statusIdx: index("plugins_status_idx").on(table.status),
+ }),
+);
diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md
new file mode 100644
index 00000000..46519da1
--- /dev/null
+++ b/packages/plugins/create-paperclip-plugin/README.md
@@ -0,0 +1,38 @@
+# @paperclipai/create-paperclip-plugin
+
+Scaffolding tool for creating new Paperclip plugins.
+
+```bash
+npx @paperclipai/create-paperclip-plugin my-plugin
+```
+
+Or with options:
+
+```bash
+npx @paperclipai/create-paperclip-plugin @acme/my-plugin \
+ --template connector \
+ --category connector \
+ --display-name "Acme Connector" \
+ --description "Syncs Acme data into Paperclip" \
+ --author "Acme Inc"
+```
+
+Supported templates: `default`, `connector`, `workspace`
+Supported categories: `connector`, `workspace`, `automation`, `ui`
+
+Generates:
+- typed manifest + worker entrypoint
+- example UI widget using `@paperclipai/plugin-sdk/ui`
+- 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`)
+
+## Workflow after scaffolding
+
+```bash
+cd my-plugin
+pnpm install
+pnpm dev # watch worker + manifest + ui bundles
+pnpm dev:ui # local UI preview server with hot-reload events
+pnpm test
+```
diff --git a/packages/plugins/create-paperclip-plugin/package.json b/packages/plugins/create-paperclip-plugin/package.json
new file mode 100644
index 00000000..e863cd6c
--- /dev/null
+++ b/packages/plugins/create-paperclip-plugin/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@paperclipai/create-paperclip-plugin",
+ "version": "0.1.0",
+ "type": "module",
+ "bin": {
+ "create-paperclip-plugin": "./dist/index.js"
+ },
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "publishConfig": {
+ "access": "public",
+ "bin": {
+ "create-paperclip-plugin": "./dist/index.js"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@paperclipai/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^24.6.0",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts
new file mode 100644
index 00000000..6d0e6c2d
--- /dev/null
+++ b/packages/plugins/create-paperclip-plugin/src/index.ts
@@ -0,0 +1,398 @@
+#!/usr/bin/env node
+import fs from "node:fs";
+import path from "node:path";
+
+const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
+type PluginTemplate = (typeof VALID_TEMPLATES)[number];
+const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const);
+
+export interface ScaffoldPluginOptions {
+ pluginName: string;
+ outputDir: string;
+ template?: PluginTemplate;
+ displayName?: string;
+ description?: string;
+ author?: string;
+ category?: "connector" | "workspace" | "automation" | "ui";
+}
+
+/** Validate npm-style plugin package names (scoped or unscoped). */
+export function isValidPluginName(name: string): boolean {
+ const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/;
+ const unscopedPattern = /^[a-z0-9._-]+$/;
+ return scopedPattern.test(name) || unscopedPattern.test(name);
+}
+
+/** Convert `@scope/name` to an output directory basename (`name`). */
+function packageToDirName(pluginName: string): string {
+ return pluginName.replace(/^@[^/]+\//, "");
+}
+
+/** Convert an npm package name into a manifest-safe plugin id. */
+function packageToManifestId(pluginName: string): string {
+ if (!pluginName.startsWith("@")) {
+ return pluginName;
+ }
+
+ return pluginName.slice(1).replace("/", ".");
+}
+
+/** Build a human-readable display name from package name tokens. */
+function makeDisplayName(pluginName: string): string {
+ const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim();
+ return raw
+ .split(/\s+/)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function writeFile(target: string, content: string) {
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, content);
+}
+
+function quote(value: string): string {
+ return JSON.stringify(value);
+}
+
+/**
+ * Generate a complete Paperclip plugin starter project.
+ *
+ * Output includes manifest/worker/UI entries, SDK harness tests, bundler presets,
+ * and a local dev server script for hot-reload workflow.
+ */
+export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
+ const template = options.template ?? "default";
+ if (!VALID_TEMPLATES.includes(template)) {
+ throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`);
+ }
+
+ if (!isValidPluginName(options.pluginName)) {
+ throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens.");
+ }
+
+ if (options.category && !VALID_CATEGORIES.has(options.category)) {
+ throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`);
+ }
+
+ const outputDir = path.resolve(options.outputDir);
+ if (fs.existsSync(outputDir)) {
+ throw new Error(`Directory already exists: ${outputDir}`);
+ }
+
+ const displayName = options.displayName ?? makeDisplayName(options.pluginName);
+ const description = options.description ?? "A Paperclip plugin";
+ const author = options.author ?? "Plugin Author";
+ const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
+ const manifestId = packageToManifestId(options.pluginName);
+
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ const packageJson = {
+ name: options.pluginName,
+ version: "0.1.0",
+ type: "module",
+ private: true,
+ description,
+ 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",
+ typecheck: "tsc --noEmit"
+ },
+ paperclipPlugin: {
+ manifest: "./dist/manifest.js",
+ worker: "./dist/worker.js",
+ ui: "./dist/ui/"
+ },
+ keywords: ["paperclip", "plugin", category],
+ author,
+ license: "MIT",
+ dependencies: {
+ "@paperclipai/plugin-sdk": "^1.0.0"
+ },
+ 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"
+ }
+ };
+
+ writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
+
+ const tsconfig = {
+ 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: "src"
+ },
+ include: ["src", "tests"],
+ exclude: ["dist", "node_modules"]
+ };
+
+ writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
+
+ writeFile(
+ path.join(outputDir, "esbuild.config.mjs"),
+ `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()]);
+}
+`,
+ );
+
+ writeFile(
+ path.join(outputDir, "rollup.config.mjs"),
+ `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);
+`,
+ );
+
+ writeFile(
+ path.join(outputDir, "src", "manifest.ts"),
+ `import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
+
+const manifest: PaperclipPluginManifestV1 = {
+ id: ${quote(manifestId)},
+ apiVersion: 1,
+ version: "0.1.0",
+ displayName: ${quote(displayName)},
+ description: ${quote(description)},
+ author: ${quote(author)},
+ categories: [${quote(category)}],
+ 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: ${quote(`${displayName} Health`)},
+ exportName: "DashboardWidget"
+ }
+ ]
+ }
+};
+
+export default manifest;
+`,
+ );
+
+ writeFile(
+ path.join(outputDir, "src", "worker.ts"),
+ `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);
+`,
+ );
+
+ writeFile(
+ path.join(outputDir, "src", "ui", "index.tsx"),
+ `import { MetricCard, StatusBadge, 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("health");
+ const ping = usePluginAction("ping");
+
+ if (loading) return Loading plugin health...
;
+ if (error) return ;
+
+ return (
+
+
+ void ping()}>Ping Worker
+
+ );
+}
+`,
+ );
+
+ writeFile(
+ path.join(outputDir, "tests", "plugin.spec.ts"),
+ `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);
+ });
+});
+`,
+ );
+
+ writeFile(
+ path.join(outputDir, "README.md"),
+ `# ${displayName}
+
+${description}
+
+## 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.
+`,
+ );
+
+ writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n");
+
+ return outputDir;
+}
+
+function parseArg(name: string): string | undefined {
+ const index = process.argv.indexOf(name);
+ if (index === -1) return undefined;
+ return process.argv[index + 1];
+}
+
+/** CLI wrapper for `scaffoldPluginProject`. */
+function runCli() {
+ const pluginName = process.argv[2];
+ if (!pluginName) {
+ // eslint-disable-next-line no-console
+ console.error("Usage: create-paperclip-plugin [--template default|connector|workspace] [--output ]");
+ process.exit(1);
+ }
+
+ const template = (parseArg("--template") ?? "default") as PluginTemplate;
+ const outputRoot = parseArg("--output") ?? process.cwd();
+ const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
+
+ const out = scaffoldPluginProject({
+ pluginName,
+ outputDir: targetDir,
+ template,
+ displayName: parseArg("--display-name"),
+ description: parseArg("--description"),
+ author: parseArg("--author"),
+ category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
+ });
+
+ // eslint-disable-next-line no-console
+ console.log(`Created plugin scaffold at ${out}`);
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ runCli();
+}
diff --git a/packages/plugins/create-paperclip-plugin/tsconfig.json b/packages/plugins/create-paperclip-plugin/tsconfig.json
new file mode 100644
index 00000000..90314411
--- /dev/null
+++ b/packages/plugins/create-paperclip-plugin/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/plugins/examples/plugin-file-browser-example/README.md b/packages/plugins/examples/plugin-file-browser-example/README.md
new file mode 100644
index 00000000..ca02fcf7
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/README.md
@@ -0,0 +1,62 @@
+# File Browser Example Plugin
+
+Example Paperclip plugin that demonstrates:
+
+- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off.
+- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
+
+This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
+
+## Slots
+
+| Slot | Type | Description |
+|---------------------|---------------------|--------------------------------------------------|
+| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
+| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
+
+## Settings
+
+- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
+- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
+
+## Capabilities
+
+- `ui.sidebar.register` — project sidebar item
+- `ui.detailTab.register` — project detail tab
+- `projects.read` — resolve project
+- `project.workspaces.read` — list workspaces and read paths for file access
+
+## Worker
+
+- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
+- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
+- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
+- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
+
+## Local Install (Dev)
+
+From the repo root, build the plugin and install it by local path:
+
+```bash
+pnpm --filter @paperclipai/plugin-file-browser-example build
+pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
+```
+
+To uninstall:
+
+```bash
+pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
+```
+
+**Local development notes:**
+
+- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
+- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
+- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
+- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
+
+## Structure
+
+- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
+- `src/worker.ts` — data handlers for workspaces, file list, file content.
+- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).
diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json
new file mode 100644
index 00000000..86c720d4
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@paperclipai/plugin-file-browser-example",
+ "version": "0.1.0",
+ "description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "paperclipPlugin": {
+ "manifest": "./dist/manifest.js",
+ "worker": "./dist/worker.js",
+ "ui": "./dist/ui/"
+ },
+ "scripts": {
+ "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
+ "build": "tsc && node ./scripts/build-ui.mjs",
+ "clean": "rm -rf dist",
+ "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
+ },
+ "dependencies": {
+ "@codemirror/lang-javascript": "^6.2.2",
+ "@codemirror/language": "^6.11.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.28.0",
+ "@lezer/highlight": "^1.2.1",
+ "@paperclipai/plugin-sdk": "workspace:*",
+ "codemirror": "^6.0.1"
+ },
+ "devDependencies": {
+ "@types/node": "^24.6.0",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "esbuild": "^0.27.3",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "typescript": "^5.7.3"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ }
+}
diff --git a/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs
new file mode 100644
index 00000000..5cd75637
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs
@@ -0,0 +1,24 @@
+import esbuild from "esbuild";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const packageRoot = path.resolve(__dirname, "..");
+
+await esbuild.build({
+ entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
+ outfile: path.join(packageRoot, "dist/ui/index.js"),
+ bundle: true,
+ format: "esm",
+ platform: "browser",
+ target: ["es2022"],
+ sourcemap: true,
+ external: [
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "@paperclipai/plugin-sdk/ui",
+ ],
+ logLevel: "info",
+});
diff --git a/packages/plugins/examples/plugin-file-browser-example/src/index.ts b/packages/plugins/examples/plugin-file-browser-example/src/index.ts
new file mode 100644
index 00000000..f301da5d
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/src/index.ts
@@ -0,0 +1,2 @@
+export { default as manifest } from "./manifest.js";
+export { default as worker } from "./worker.js";
diff --git a/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts
new file mode 100644
index 00000000..027c134b
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts
@@ -0,0 +1,85 @@
+import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
+
+const PLUGIN_ID = "paperclip-file-browser-example";
+const FILES_SIDEBAR_SLOT_ID = "files-link";
+const FILES_TAB_SLOT_ID = "files-tab";
+const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links";
+const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files";
+
+const manifest: PaperclipPluginManifestV1 = {
+ id: PLUGIN_ID,
+ apiVersion: 1,
+ version: "0.2.0",
+ displayName: "File Browser (Example)",
+ description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.",
+ author: "Paperclip",
+ categories: ["workspace", "ui"],
+ capabilities: [
+ "ui.sidebar.register",
+ "ui.detailTab.register",
+ "ui.commentAnnotation.register",
+ "ui.action.register",
+ "projects.read",
+ "project.workspaces.read",
+ "issue.comments.read",
+ "plugin.state.read",
+ ],
+ instanceConfigSchema: {
+ type: "object",
+ properties: {
+ showFilesInSidebar: {
+ type: "boolean",
+ title: "Show Files in Sidebar",
+ default: false,
+ description: "Adds the Files link under each project in the sidebar.",
+ },
+ commentAnnotationMode: {
+ type: "string",
+ title: "Comment File Links",
+ enum: ["annotation", "contextMenu", "both", "none"],
+ default: "both",
+ description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.",
+ },
+ },
+ },
+ entrypoints: {
+ worker: "./dist/worker.js",
+ ui: "./dist/ui",
+ },
+ ui: {
+ slots: [
+ {
+ type: "projectSidebarItem",
+ id: FILES_SIDEBAR_SLOT_ID,
+ displayName: "Files",
+ exportName: "FilesLink",
+ entityTypes: ["project"],
+ order: 10,
+ },
+ {
+ type: "detailTab",
+ id: FILES_TAB_SLOT_ID,
+ displayName: "Files",
+ exportName: "FilesTab",
+ entityTypes: ["project"],
+ order: 10,
+ },
+ {
+ type: "commentAnnotation",
+ id: COMMENT_FILE_LINKS_SLOT_ID,
+ displayName: "File Links",
+ exportName: "CommentFileLinks",
+ entityTypes: ["comment"],
+ },
+ {
+ type: "commentContextMenuItem",
+ id: COMMENT_OPEN_FILES_SLOT_ID,
+ displayName: "Open in Files",
+ exportName: "CommentOpenFiles",
+ entityTypes: ["comment"],
+ },
+ ],
+ },
+};
+
+export default manifest;
diff --git a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx
new file mode 100644
index 00000000..0e12d903
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx
@@ -0,0 +1,815 @@
+import type {
+ PluginProjectSidebarItemProps,
+ PluginDetailTabProps,
+ PluginCommentAnnotationProps,
+ PluginCommentContextMenuItemProps,
+} from "@paperclipai/plugin-sdk/ui";
+import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
+import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
+import { EditorView } from "@codemirror/view";
+import { basicSetup } from "codemirror";
+import { javascript } from "@codemirror/lang-javascript";
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { tags } from "@lezer/highlight";
+
+const PLUGIN_KEY = "paperclip-file-browser-example";
+const FILES_TAB_SLOT_ID = "files-tab";
+
+const editorBaseTheme = {
+ "&": {
+ height: "100%",
+ },
+ ".cm-scroller": {
+ overflow: "auto",
+ fontFamily:
+ "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace",
+ fontSize: "13px",
+ lineHeight: "1.6",
+ },
+ ".cm-content": {
+ padding: "12px 14px 18px",
+ },
+};
+
+const editorDarkTheme = EditorView.theme({
+ ...editorBaseTheme,
+ "&": {
+ ...editorBaseTheme["&"],
+ backgroundColor: "oklch(0.23 0.02 255)",
+ color: "oklch(0.93 0.01 255)",
+ },
+ ".cm-gutters": {
+ backgroundColor: "oklch(0.25 0.015 255)",
+ color: "oklch(0.74 0.015 255)",
+ borderRight: "1px solid oklch(0.34 0.01 255)",
+ },
+ ".cm-activeLine, .cm-activeLineGutter": {
+ backgroundColor: "oklch(0.30 0.012 255 / 0.55)",
+ },
+ ".cm-selectionBackground, .cm-content ::selection": {
+ backgroundColor: "oklch(0.42 0.02 255 / 0.45)",
+ },
+ "&.cm-focused .cm-selectionBackground": {
+ backgroundColor: "oklch(0.47 0.025 255 / 0.5)",
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeftColor: "oklch(0.93 0.01 255)",
+ },
+ ".cm-matchingBracket": {
+ backgroundColor: "oklch(0.37 0.015 255 / 0.5)",
+ color: "oklch(0.95 0.01 255)",
+ outline: "none",
+ },
+ ".cm-nonmatchingBracket": {
+ color: "oklch(0.70 0.08 24)",
+ },
+}, { dark: true });
+
+const editorLightTheme = EditorView.theme({
+ ...editorBaseTheme,
+ "&": {
+ ...editorBaseTheme["&"],
+ backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))",
+ color: "var(--foreground)",
+ },
+ ".cm-content": {
+ ...editorBaseTheme[".cm-content"],
+ caretColor: "var(--foreground)",
+ },
+ ".cm-gutters": {
+ backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))",
+ color: "var(--muted-foreground)",
+ borderRight: "1px solid var(--border)",
+ },
+ ".cm-activeLine, .cm-activeLineGutter": {
+ backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)",
+ },
+ ".cm-selectionBackground, .cm-content ::selection": {
+ backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)",
+ },
+ "&.cm-focused .cm-selectionBackground": {
+ backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)",
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)",
+ },
+ ".cm-matchingBracket": {
+ backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)",
+ color: "var(--foreground)",
+ outline: "none",
+ },
+ ".cm-nonmatchingBracket": {
+ color: "var(--destructive)",
+ },
+});
+
+const editorDarkHighlightStyle = HighlightStyle.define([
+ { tag: tags.keyword, color: "oklch(0.78 0.025 265)" },
+ { tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" },
+ { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" },
+ { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" },
+ { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" },
+ { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" },
+ { tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" },
+ { tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" },
+ { tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" },
+]);
+
+const editorLightHighlightStyle = HighlightStyle.define([
+ { tag: tags.keyword, color: "oklch(0.45 0.07 270)" },
+ { tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" },
+ { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" },
+ { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" },
+ { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" },
+ { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" },
+ { tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" },
+ { tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" },
+ { tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" },
+]);
+
+type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
+type FileEntry = { name: string; path: string; isDirectory: boolean };
+type FileTreeNodeProps = {
+ entry: FileEntry;
+ companyId: string | null;
+ projectId: string;
+ workspaceId: string;
+ selectedPath: string | null;
+ onSelect: (path: string) => void;
+ depth?: number;
+};
+
+const PathLikePattern = /[\\/]/;
+const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
+const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function isLikelyPath(pathValue: string): boolean {
+ const trimmed = pathValue.trim();
+ return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed);
+}
+
+function workspaceLabel(workspace: Workspace): string {
+ const pathLabel = workspace.path.trim();
+ const nameLabel = workspace.name.trim();
+ const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel);
+ const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel);
+ const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : "";
+ if (!baseLabel) {
+ return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)";
+ }
+
+ return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel;
+}
+
+function useIsMobile(breakpointPx = 768): boolean {
+ const [isMobile, setIsMobile] = useState(() =>
+ typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
+ );
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
+ const update = () => setIsMobile(mediaQuery.matches);
+ update();
+ mediaQuery.addEventListener("change", update);
+ return () => mediaQuery.removeEventListener("change", update);
+ }, [breakpointPx]);
+
+ return isMobile;
+}
+
+function useIsDarkMode(): boolean {
+ const [isDarkMode, setIsDarkMode] = useState(() =>
+ typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
+ );
+
+ useEffect(() => {
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ const update = () => setIsDarkMode(root.classList.contains("dark"));
+ update();
+
+ const observer = new MutationObserver(update);
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
+ return () => observer.disconnect();
+ }, []);
+
+ return isDarkMode;
+}
+
+function useAvailableHeight(
+ ref: RefObject,
+ options?: { bottomPadding?: number; minHeight?: number },
+): number | null {
+ const bottomPadding = options?.bottomPadding ?? 24;
+ const minHeight = options?.minHeight ?? 384;
+ const [height, setHeight] = useState(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const update = () => {
+ const element = ref.current;
+ if (!element) return;
+ const rect = element.getBoundingClientRect();
+ const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding));
+ setHeight(nextHeight);
+ };
+
+ update();
+ window.addEventListener("resize", update);
+ window.addEventListener("orientationchange", update);
+
+ const observer = typeof ResizeObserver !== "undefined"
+ ? new ResizeObserver(() => update())
+ : null;
+ if (observer && ref.current) observer.observe(ref.current);
+
+ return () => {
+ window.removeEventListener("resize", update);
+ window.removeEventListener("orientationchange", update);
+ observer?.disconnect();
+ };
+ }, [bottomPadding, minHeight, ref]);
+
+ return height;
+}
+
+function FileTreeNode({
+ entry,
+ companyId,
+ projectId,
+ workspaceId,
+ selectedPath,
+ onSelect,
+ depth = 0,
+}: FileTreeNodeProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const isSelected = selectedPath === entry.path;
+
+ if (entry.isDirectory) {
+ return (
+
+ setIsExpanded((value) => !value)}
+ aria-expanded={isExpanded}
+ >
+ {isExpanded ? "▾" : "▸"}
+ {entry.name}
+
+ {isExpanded ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ onSelect(entry.path)}
+ >
+ {entry.name}
+
+
+ );
+}
+
+function ExpandedDirectoryChildren({
+ directoryPath,
+ companyId,
+ projectId,
+ workspaceId,
+ selectedPath,
+ onSelect,
+ depth,
+}: {
+ directoryPath: string;
+ companyId: string | null;
+ projectId: string;
+ workspaceId: string;
+ selectedPath: string | null;
+ onSelect: (path: string) => void;
+ depth: number;
+}) {
+ const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
+ companyId,
+ projectId,
+ workspaceId,
+ directoryPath,
+ });
+ const children = childData?.entries ?? [];
+
+ if (children.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {children.map((child) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
+ */
+export function FilesLink({ context }: PluginProjectSidebarItemProps) {
+ const { data: config, loading: configLoading } = usePluginData("plugin-config", {});
+ const showFilesInSidebar = config?.showFilesInSidebar ?? false;
+
+ if (configLoading || !showFilesInSidebar) {
+ return null;
+ }
+
+ const projectId = context.entityId;
+ const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null })
+ .projectRef
+ ?? projectId;
+ const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
+ const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
+ const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`;
+ const isActive = typeof window !== "undefined" && (() => {
+ const pathname = window.location.pathname.replace(/\/+$/, "");
+ const segments = pathname.split("/").filter(Boolean);
+ const projectsIndex = segments.indexOf("projects");
+ const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null;
+ const activeTab = new URLSearchParams(window.location.search).get("tab");
+ if (activeTab !== tabValue) return false;
+ if (!activeProjectRef) return false;
+ return activeProjectRef === projectId || activeProjectRef === projectRef;
+ })();
+
+ const handleClick = (event: MouseEvent) => {
+ if (
+ event.defaultPrevented
+ || event.button !== 0
+ || event.metaKey
+ || event.ctrlKey
+ || event.altKey
+ || event.shiftKey
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ window.history.pushState({}, "", href);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ };
+
+ return (
+
+ Files
+
+ );
+}
+
+/**
+ * Project detail tab: workspace selector, file tree, and CodeMirror editor.
+ */
+export function FilesTab({ context }: PluginDetailTabProps) {
+ const companyId = context.companyId;
+ const projectId = context.entityId;
+ const isMobile = useIsMobile();
+ const isDarkMode = useIsDarkMode();
+ const panesRef = useRef(null);
+ const availableHeight = useAvailableHeight(panesRef, {
+ bottomPadding: isMobile ? 16 : 24,
+ minHeight: isMobile ? 320 : 420,
+ });
+ const { data: workspacesData } = usePluginData("workspaces", {
+ projectId,
+ companyId,
+ });
+ const workspaces = workspacesData ?? [];
+ const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|");
+ const [workspaceId, setWorkspaceId] = useState(null);
+ const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null;
+ const selectedWorkspace = useMemo(
+ () => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null,
+ [workspaces, resolvedWorkspaceId],
+ );
+
+ const fileListParams = useMemo(
+ () => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
+ [companyId, projectId, selectedWorkspace],
+ );
+ const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
+ "fileList",
+ fileListParams,
+ );
+ const entries = fileListData?.entries ?? [];
+
+ // Track the `?file=` query parameter across navigations (popstate).
+ const [urlFilePath, setUrlFilePath] = useState(() => {
+ if (typeof window === "undefined") return null;
+ return new URLSearchParams(window.location.search).get("file") || null;
+ });
+ const lastConsumedFileRef = useRef(null);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const onNav = () => {
+ const next = new URLSearchParams(window.location.search).get("file") || null;
+ setUrlFilePath(next);
+ };
+ window.addEventListener("popstate", onNav);
+ return () => window.removeEventListener("popstate", onNav);
+ }, []);
+
+ const [selectedPath, setSelectedPath] = useState(null);
+ useEffect(() => {
+ setSelectedPath(null);
+ setMobileView("browser");
+ lastConsumedFileRef.current = null;
+ }, [selectedWorkspace?.id]);
+
+ // When a file path appears (or changes) in the URL and workspace is ready, select it.
+ useEffect(() => {
+ if (!urlFilePath || !selectedWorkspace) return;
+ if (lastConsumedFileRef.current === urlFilePath) return;
+ lastConsumedFileRef.current = urlFilePath;
+ setSelectedPath(urlFilePath);
+ setMobileView("editor");
+ }, [urlFilePath, selectedWorkspace]);
+
+ const fileContentParams = useMemo(
+ () =>
+ selectedPath && selectedWorkspace
+ ? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath }
+ : null,
+ [companyId, projectId, selectedWorkspace, selectedPath],
+ );
+ const fileContentResult = usePluginData<{ content: string | null; error?: string }>(
+ "fileContent",
+ fileContentParams ?? {},
+ );
+ const { data: fileContentData, refresh: refreshFileContent } = fileContentResult;
+ const writeFile = usePluginAction("writeFile");
+ const editorRef = useRef(null);
+ const viewRef = useRef(null);
+ const loadedContentRef = useRef("");
+ const [isDirty, setIsDirty] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveMessage, setSaveMessage] = useState(null);
+ const [saveError, setSaveError] = useState(null);
+ const [mobileView, setMobileView] = useState<"browser" | "editor">("browser");
+
+ useEffect(() => {
+ if (!editorRef.current) return;
+ const content = fileContentData?.content ?? "";
+ loadedContentRef.current = content;
+ setIsDirty(false);
+ setSaveMessage(null);
+ setSaveError(null);
+ if (viewRef.current) {
+ viewRef.current.destroy();
+ viewRef.current = null;
+ }
+ const view = new EditorView({
+ doc: content,
+ extensions: [
+ basicSetup,
+ javascript(),
+ isDarkMode ? editorDarkTheme : editorLightTheme,
+ syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle),
+ EditorView.updateListener.of((update) => {
+ if (!update.docChanged) return;
+ const nextValue = update.state.doc.toString();
+ setIsDirty(nextValue !== loadedContentRef.current);
+ setSaveMessage(null);
+ setSaveError(null);
+ }),
+ ],
+ parent: editorRef.current,
+ });
+ viewRef.current = view;
+ return () => {
+ view.destroy();
+ viewRef.current = null;
+ };
+ }, [fileContentData?.content, selectedPath, isDarkMode]);
+
+ useEffect(() => {
+ const handleKeydown = (event: KeyboardEvent) => {
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
+ return;
+ }
+ if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) {
+ return;
+ }
+ event.preventDefault();
+ void handleSave();
+ };
+
+ window.addEventListener("keydown", handleKeydown);
+ return () => window.removeEventListener("keydown", handleKeydown);
+ }, [selectedWorkspace, selectedPath, isDirty, isSaving]);
+
+ async function handleSave() {
+ if (!selectedWorkspace || !selectedPath || !viewRef.current) {
+ return;
+ }
+ const content = viewRef.current.state.doc.toString();
+ setIsSaving(true);
+ setSaveError(null);
+ setSaveMessage(null);
+ try {
+ await writeFile({
+ projectId,
+ companyId,
+ workspaceId: selectedWorkspace.id,
+ filePath: selectedPath,
+ content,
+ });
+ loadedContentRef.current = content;
+ setIsDirty(false);
+ setSaveMessage("Saved");
+ refreshFileContent();
+ } catch (error) {
+ setSaveError(error instanceof Error ? error.message : String(error));
+ } finally {
+ setIsSaving(false);
+ }
+ }
+
+ return (
+
+
+ Workspace
+ setWorkspaceId(e.target.value || null)}
+ >
+ {workspaces.map((w) => {
+ const label = workspaceLabel(w);
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+
+ File Tree
+
+
+ {selectedWorkspace ? (
+ fileListLoading ? (
+
Loading files...
+ ) : entries.length > 0 ? (
+
+ {entries.map((entry) => (
+ {
+ setSelectedPath(path);
+ setMobileView("editor");
+ }}
+ />
+ ))}
+
+ ) : (
+
No files found in this workspace.
+ )
+ ) : (
+
Select a workspace to browse files.
+ )}
+
+
+
+
+
+
setMobileView("browser")}
+ >
+ Back to files
+
+
Editor
+
{selectedPath ?? "No file selected"}
+
+
+ void handleSave()}
+ >
+ {isSaving ? "Saving..." : "Save"}
+
+
+
+ {isDirty || saveMessage || saveError ? (
+
+ {saveError ? (
+ {saveError}
+ ) : saveMessage ? (
+ {saveMessage}
+ ) : (
+ Unsaved changes
+ )}
+
+ ) : null}
+ {selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
+
{fileContentData.error}
+ ) : null}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Comment Annotation: renders detected file links below each comment
+// ---------------------------------------------------------------------------
+
+type PluginConfig = {
+ showFilesInSidebar?: boolean;
+ commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none";
+};
+
+/**
+ * Per-comment annotation showing file-path-like links extracted from the
+ * comment body. Each link navigates to the project Files tab with the
+ * matching path pre-selected.
+ *
+ * Respects the `commentAnnotationMode` instance config — hidden when mode
+ * is `"contextMenu"` or `"none"`.
+ */
+function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string {
+ if (!projectId) return "#";
+ const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
+ return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`;
+}
+
+function navigateToFileBrowser(href: string, event: MouseEvent) {
+ if (
+ event.defaultPrevented
+ || event.button !== 0
+ || event.metaKey
+ || event.ctrlKey
+ || event.altKey
+ || event.shiftKey
+ ) {
+ return;
+ }
+ event.preventDefault();
+ window.history.pushState({}, "", href);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
+
+export function CommentFileLinks({ context }: PluginCommentAnnotationProps) {
+ const { data: config } = usePluginData("plugin-config", {});
+ const mode = config?.commentAnnotationMode ?? "both";
+
+ const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
+ commentId: context.entityId,
+ issueId: context.parentEntityId,
+ companyId: context.companyId,
+ });
+
+ if (mode === "contextMenu" || mode === "none") return null;
+ if (!data?.links?.length) return null;
+
+ const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
+ const projectId = context.projectId;
+
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Comment Context Menu Item: "Open in Files" action per comment
+// ---------------------------------------------------------------------------
+
+/**
+ * Per-comment context menu item that appears in the comment "more" (⋮) menu.
+ * Extracts file paths from the comment body and, if any are found, renders
+ * a button to open the first file in the project Files tab.
+ *
+ * Respects the `commentAnnotationMode` instance config — hidden when mode
+ * is `"annotation"` or `"none"`.
+ */
+export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) {
+ const { data: config } = usePluginData("plugin-config", {});
+ const mode = config?.commentAnnotationMode ?? "both";
+
+ const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
+ commentId: context.entityId,
+ issueId: context.parentEntityId,
+ companyId: context.companyId,
+ });
+
+ if (mode === "annotation" || mode === "none") return null;
+ if (!data?.links?.length) return null;
+
+ const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
+ const projectId = context.projectId;
+
+ return (
+
+ );
+}
diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts
new file mode 100644
index 00000000..1c39af75
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts
@@ -0,0 +1,226 @@
+import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
+import * as fs from "node:fs";
+import * as path from "node:path";
+
+const PLUGIN_NAME = "file-browser-example";
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const PATH_LIKE_PATTERN = /[\\/]/;
+const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
+
+function looksLikePath(value: string): boolean {
+ const normalized = value.trim();
+ return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized))
+ && !UUID_PATTERN.test(normalized);
+}
+
+function sanitizeWorkspacePath(pathValue: string): string {
+ return looksLikePath(pathValue) ? pathValue.trim() : "";
+}
+
+function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null {
+ const root = path.resolve(workspacePath);
+ const resolved = requestedPath ? path.resolve(root, requestedPath) : root;
+ const relative = path.relative(root, resolved);
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ return null;
+ }
+ return resolved;
+}
+
+/**
+ * Regex that matches file-path-like tokens in comment text.
+ * Captures tokens that either start with `.` `/` `~` or contain a `/`
+ * (directory separator), plus bare words that could be filenames with
+ * extensions (e.g. `README.md`). The file-extension check in
+ * `extractFilePaths` filters out non-file matches.
+ */
+const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g;
+
+/** Common file extensions to recognise path-like tokens as actual file references. */
+const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/;
+
+/**
+ * Tokens that look like paths but are almost certainly URL route segments
+ * (e.g. `/projects/abc`, `/settings`, `/dashboard`).
+ */
+const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i;
+
+function extractFilePaths(body: string): string[] {
+ const paths = new Set();
+ for (const match of body.matchAll(FILE_PATH_REGEX)) {
+ const raw = match[1];
+ // Strip trailing punctuation that isn't part of a path
+ const cleaned = raw.replace(/[.:,;!?)]+$/, "");
+ if (cleaned.length <= 1) continue;
+ // Must have a file extension (e.g. .ts, .json, .md)
+ if (!FILE_EXTENSION_REGEX.test(cleaned)) continue;
+ // Skip things that look like URL routes
+ if (URL_ROUTE_PATTERN.test(cleaned)) continue;
+ paths.add(cleaned);
+ }
+ return [...paths];
+}
+
+const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
+
+ // Expose the current plugin config so UI components can read the
+ // commentAnnotationMode setting and hide themselves when disabled.
+ ctx.data.register("plugin-config", async () => {
+ const config = await ctx.state.get({ scopeKind: "instance", stateKey: "config" }) as Record | null;
+ return {
+ showFilesInSidebar: config?.showFilesInSidebar === true,
+ commentAnnotationMode: config?.commentAnnotationMode ?? "both",
+ };
+ });
+
+ // Fetch a comment by ID and extract file-path-like tokens from its body.
+ ctx.data.register("comment-file-links", async (params: Record) => {
+ const commentId = typeof params.commentId === "string" ? params.commentId : "";
+ const issueId = typeof params.issueId === "string" ? params.issueId : "";
+ const companyId = typeof params.companyId === "string" ? params.companyId : "";
+ if (!commentId || !issueId || !companyId) return { links: [] };
+ try {
+ const comments = await ctx.issues.listComments(issueId, companyId);
+ const comment = comments.find((c) => c.id === commentId);
+ if (!comment?.body) return { links: [] };
+ return { links: extractFilePaths(comment.body) };
+ } catch (err) {
+ ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) });
+ return { links: [] };
+ }
+ });
+
+ ctx.data.register("workspaces", async (params: Record) => {
+ const projectId = params.projectId as string;
+ const companyId = typeof params.companyId === "string" ? params.companyId : "";
+ if (!projectId || !companyId) return [];
+ const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
+ return workspaces.map((w) => ({
+ id: w.id,
+ projectId: w.projectId,
+ name: w.name,
+ path: sanitizeWorkspacePath(w.path),
+ isPrimary: w.isPrimary,
+ }));
+ });
+
+ ctx.data.register(
+ "fileList",
+ async (params: Record) => {
+ const projectId = params.projectId as string;
+ const companyId = typeof params.companyId === "string" ? params.companyId : "";
+ const workspaceId = params.workspaceId as string;
+ const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
+ if (!projectId || !companyId || !workspaceId) return { entries: [] };
+ const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
+ const workspace = workspaces.find((w) => w.id === workspaceId);
+ if (!workspace) return { entries: [] };
+ const workspacePath = sanitizeWorkspacePath(workspace.path);
+ if (!workspacePath) return { entries: [] };
+ const dirPath = resolveWorkspace(workspacePath, directoryPath);
+ if (!dirPath) {
+ return { entries: [] };
+ }
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
+ return { entries: [] };
+ }
+ const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
+ const entries = names.map((name) => {
+ const full = path.join(dirPath, name);
+ const stat = fs.lstatSync(full);
+ const relativePath = path.relative(workspacePath, full);
+ return {
+ name,
+ path: relativePath,
+ isDirectory: stat.isDirectory(),
+ };
+ }).sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ return { entries };
+ },
+ );
+
+ ctx.data.register(
+ "fileContent",
+ async (params: Record) => {
+ const projectId = params.projectId as string;
+ const companyId = typeof params.companyId === "string" ? params.companyId : "";
+ const workspaceId = params.workspaceId as string;
+ const filePath = params.filePath as string;
+ if (!projectId || !companyId || !workspaceId || !filePath) {
+ return { content: null, error: "Missing file context" };
+ }
+ const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
+ const workspace = workspaces.find((w) => w.id === workspaceId);
+ if (!workspace) return { content: null, error: "Workspace not found" };
+ const workspacePath = sanitizeWorkspacePath(workspace.path);
+ if (!workspacePath) return { content: null, error: "Workspace has no path" };
+ const fullPath = resolveWorkspace(workspacePath, filePath);
+ if (!fullPath) {
+ return { content: null, error: "Path outside workspace" };
+ }
+ try {
+ const content = fs.readFileSync(fullPath, "utf-8");
+ return { content };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { content: null, error: message };
+ }
+ },
+ );
+
+ ctx.actions.register(
+ "writeFile",
+ async (params: Record) => {
+ const projectId = params.projectId as string;
+ const companyId = typeof params.companyId === "string" ? params.companyId : "";
+ const workspaceId = params.workspaceId as string;
+ const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
+ if (!filePath) {
+ throw new Error("filePath must be a non-empty string");
+ }
+ const content = typeof params.content === "string" ? params.content : null;
+ if (!projectId || !companyId || !workspaceId) {
+ throw new Error("Missing workspace context");
+ }
+ const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
+ const workspace = workspaces.find((w) => w.id === workspaceId);
+ if (!workspace) {
+ throw new Error("Workspace not found");
+ }
+ const workspacePath = sanitizeWorkspacePath(workspace.path);
+ if (!workspacePath) {
+ throw new Error("Workspace has no path");
+ }
+ if (content === null) {
+ throw new Error("Missing file content");
+ }
+ const fullPath = resolveWorkspace(workspacePath, filePath);
+ if (!fullPath) {
+ throw new Error("Path outside workspace");
+ }
+ const stat = fs.statSync(fullPath);
+ if (!stat.isFile()) {
+ throw new Error("Selected path is not a file");
+ }
+ fs.writeFileSync(fullPath, content, "utf-8");
+ return {
+ ok: true,
+ path: filePath,
+ bytes: Buffer.byteLength(content, "utf-8"),
+ };
+ },
+ );
+ },
+
+ async onHealth() {
+ return { status: "ok", message: `${PLUGIN_NAME} ready` };
+ },
+});
+
+export default plugin;
+runWorker(plugin, import.meta.url);
diff --git a/packages/plugins/examples/plugin-file-browser-example/tsconfig.json b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json
new file mode 100644
index 00000000..3482c173
--- /dev/null
+++ b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "lib": ["ES2023", "DOM"],
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/packages/plugins/examples/plugin-hello-world-example/README.md b/packages/plugins/examples/plugin-hello-world-example/README.md
new file mode 100644
index 00000000..889c9d25
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/README.md
@@ -0,0 +1,38 @@
+# @paperclipai/plugin-hello-world-example
+
+First-party reference plugin showing the smallest possible UI extension.
+
+## What It Demonstrates
+
+- a manifest with a `dashboardWidget` UI slot
+- `entrypoints.ui` wiring for plugin UI bundles
+- a minimal React widget rendered in the Paperclip dashboard
+- reading host context (`companyId`) from `PluginWidgetProps`
+- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability
+
+## API Surface
+
+- This example does not add custom HTTP endpoints.
+- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`).
+
+## Notes
+
+This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors.
+It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds.
+
+## Local Install (Dev)
+
+From the repo root, build the plugin and install it by local path:
+
+```bash
+pnpm --filter @paperclipai/plugin-hello-world-example build
+pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
+```
+
+**Local development notes:**
+
+- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
+- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
+- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
+ `pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
+ `pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
diff --git a/packages/plugins/examples/plugin-hello-world-example/package.json b/packages/plugins/examples/plugin-hello-world-example/package.json
new file mode 100644
index 00000000..5d055caa
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@paperclipai/plugin-hello-world-example",
+ "version": "0.1.0",
+ "description": "First-party reference plugin that adds a Hello World dashboard widget",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "paperclipPlugin": {
+ "manifest": "./dist/manifest.js",
+ "worker": "./dist/worker.js",
+ "ui": "./dist/ui/"
+ },
+ "scripts": {
+ "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
+ },
+ "dependencies": {
+ "@paperclipai/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^24.6.0",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "typescript": "^5.7.3"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ }
+}
diff --git a/packages/plugins/examples/plugin-hello-world-example/src/index.ts b/packages/plugins/examples/plugin-hello-world-example/src/index.ts
new file mode 100644
index 00000000..f301da5d
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/src/index.ts
@@ -0,0 +1,2 @@
+export { default as manifest } from "./manifest.js";
+export { default as worker } from "./worker.js";
diff --git a/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts
new file mode 100644
index 00000000..2fcd8077
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts
@@ -0,0 +1,39 @@
+import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
+
+/**
+ * Stable plugin ID used by host registration and namespacing.
+ */
+const PLUGIN_ID = "paperclip.hello-world-example";
+const PLUGIN_VERSION = "0.1.0";
+const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget";
+const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget";
+
+/**
+ * Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot.
+ */
+const manifest: PaperclipPluginManifestV1 = {
+ id: PLUGIN_ID,
+ apiVersion: 1,
+ version: PLUGIN_VERSION,
+ displayName: "Hello World Widget (Example)",
+ description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
+ author: "Paperclip",
+ categories: ["ui"],
+ capabilities: ["ui.dashboardWidget.register"],
+ entrypoints: {
+ worker: "./dist/worker.js",
+ ui: "./dist/ui",
+ },
+ ui: {
+ slots: [
+ {
+ type: "dashboardWidget",
+ id: DASHBOARD_WIDGET_SLOT_ID,
+ displayName: "Hello World",
+ exportName: DASHBOARD_WIDGET_EXPORT_NAME,
+ },
+ ],
+ },
+};
+
+export default manifest;
diff --git a/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx
new file mode 100644
index 00000000..10e12fb0
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx
@@ -0,0 +1,17 @@
+import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
+
+const WIDGET_LABEL = "Hello world plugin widget";
+
+/**
+ * Example dashboard widget showing the smallest possible UI contribution.
+ */
+export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) {
+ return (
+
+ Hello world
+ This widget was added by @paperclipai/plugin-hello-world-example.
+ {/* Include host context so authors can see where scoped IDs come from. */}
+ Company context: {context.companyId}
+
+ );
+}
diff --git a/packages/plugins/examples/plugin-hello-world-example/src/worker.ts b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts
new file mode 100644
index 00000000..07c7fbea
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts
@@ -0,0 +1,27 @@
+import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
+
+const PLUGIN_NAME = "hello-world-example";
+const HEALTH_MESSAGE = "Hello World example plugin ready";
+
+/**
+ * Worker lifecycle hooks for the Hello World reference plugin.
+ * This stays intentionally small so new authors can copy the shape quickly.
+ */
+const plugin = definePlugin({
+ /**
+ * Called when the host starts the plugin worker.
+ */
+ async setup(ctx) {
+ ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`);
+ },
+
+ /**
+ * Called by the host health probe endpoint.
+ */
+ async onHealth() {
+ return { status: "ok", message: HEALTH_MESSAGE };
+ },
+});
+
+export default plugin;
+runWorker(plugin, import.meta.url);
diff --git a/packages/plugins/examples/plugin-hello-world-example/tsconfig.json b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json
new file mode 100644
index 00000000..3482c173
--- /dev/null
+++ b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "lib": ["ES2023", "DOM"],
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md
new file mode 100644
index 00000000..66cdebcb
--- /dev/null
+++ b/packages/plugins/sdk/README.md
@@ -0,0 +1,959 @@
+# `@paperclipai/plugin-sdk`
+
+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
+- **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
+
+Reference: `doc/plugins/PLUGIN_SPEC.md`
+
+## Package surface
+
+| 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/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` |
+| `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |
+| `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) |
+
+## Manifest entrypoints
+
+In your plugin manifest you declare:
+
+- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.
+- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
+
+## Install
+
+```bash
+pnpm add @paperclipai/plugin-sdk
+```
+
+## Current deployment caveats
+
+The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
+
+- 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.
+
+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.
+
+## Worker quick start
+
+```ts
+import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
+
+const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.events.on("issue.created", async (event) => {
+ ctx.logger.info("Issue created", { issueId: event.entityId });
+ });
+
+ ctx.data.register("health", async () => ({ status: "ok" }));
+ ctx.actions.register("ping", async () => ({ pong: true }));
+
+ ctx.tools.register("calculator", {
+ displayName: "Calculator",
+ description: "Basic math",
+ parametersSchema: {
+ type: "object",
+ properties: { a: { type: "number" }, b: { type: "number" } },
+ required: ["a", "b"]
+ }
+ }, async (params) => {
+ const { a, b } = params as { a: number; b: number };
+ return { content: `Result: ${a + b}`, data: { result: a + b } };
+ });
+ },
+});
+
+export default plugin;
+runWorker(plugin, import.meta.url);
+```
+
+**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting.
+
+### Worker lifecycle and context
+
+**Lifecycle (definePlugin):**
+
+| Hook | Purpose |
+|------|--------|
+| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |
+| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |
+| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |
+| `onShutdown?()` | Optional. Clean up before process exit (limited time window). |
+| `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.
+
+**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.
+
+**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
+
+## Events
+
+Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
+
+**Core domain events (subscribe with `events.subscribe`):**
+
+| Event | Typical entity |
+|-------|-----------------|
+| `company.created`, `company.updated` | company |
+| `project.created`, `project.updated` | project |
+| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
+| `issue.created`, `issue.updated`, `issue.comment.created` | issue |
+| `agent.created`, `agent.updated`, `agent.status_changed` | agent |
+| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
+| `goal.created`, `goal.updated` | goal |
+| `approval.created`, `approval.decided` | approval |
+| `cost_event.created` | cost |
+| `activity.logged` | activity |
+
+**Plugin-to-plugin:** Subscribe to `plugin..` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit("sync-done", companyId, payload)`; the host namespaces it automatically.
+
+**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.
+
+## Scheduled (recurring) jobs
+
+Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.
+
+1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.
+2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).
+3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.
+
+**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):
+
+| Field | Values | Example |
+|-------------|----------|---------|
+| minute | 0–59 | `0`, `*/15` |
+| hour | 0–23 | `2`, `*` |
+| day of month | 1–31 | `1`, `*` |
+| month | 1–12 | `*` |
+| day of week | 0–6 (Sun=0) | `*`, `1-5` |
+
+Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00.
+
+**Job handler context** (`PluginJobContext`):
+
+| Field | Type | Description |
+|-------------|----------|-------------|
+| `jobKey` | string | Matches the manifest declaration. |
+| `runId` | string | UUID for this run. |
+| `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. |
+| `scheduledAt` | string | ISO 8601 time when the run was scheduled. |
+
+Runs can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.
+
+Example:
+
+**Manifest** — include `jobs.schedule` and declare the job:
+
+```ts
+// In your manifest (e.g. manifest.ts):
+const manifest = {
+ // ...
+ capabilities: ["jobs.schedule", "plugin.state.write"],
+ jobs: [
+ {
+ jobKey: "heartbeat",
+ displayName: "Heartbeat",
+ description: "Runs every 5 minutes",
+ schedule: "*/5 * * * *",
+ },
+ ],
+ // ...
+};
+```
+
+**Worker** — register the handler in `setup()`:
+
+```ts
+ctx.jobs.register("heartbeat", async (job) => {
+ ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
+ await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
+});
+```
+
+## UI slots and launchers
+
+Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`).
+
+### Slot types / launcher placement zones
+
+The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
+
+| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
+|----------------------------|-------|---------------------------------------|
+| `page` | Global | — |
+| `sidebar` | Global | — |
+| `sidebarPanel` | Global | — |
+| `settingsPage` | Global | — |
+| `dashboardWidget` | Global | — |
+| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
+| `taskDetailView` | Entity | (task/issue context) |
+| `commentAnnotation` | Entity | `comment` |
+| `commentContextMenuItem` | Entity | `comment` |
+| `projectSidebarItem` | Entity | `project` |
+| `toolbarButton` | Entity | varies by host surface |
+| `contextMenuItem` | Entity | varies by host surface |
+
+**Scope** describes whether the slot requires an entity to render. **Global** slots render without a specific entity but still receive the active `companyId` through `PluginHostContext` — use it to scope data fetches to the current company. **Entity** slots additionally require `entityId` and `entityType` (e.g. a detail tab on a specific issue).
+
+**Entity types** (for `entityTypes` on slots): `project` \| `issue` \| `agent` \| `goal` \| `run` \| `comment`. Full list: import `PLUGIN_UI_SLOT_TYPES` and `PLUGIN_UI_SLOT_ENTITY_TYPES` from `@paperclipai/plugin-sdk`.
+
+### Slot component descriptions
+
+#### `page`
+
+A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-scoped). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability.
+
+#### `sidebar`
+
+Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
+
+#### `sidebarPanel`
+
+Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
+
+#### `settingsPage`
+
+Replaces the auto-generated JSON Schema settings form with a custom React component. Use this when the default form is insufficient — for example, when your plugin needs multi-step configuration, OAuth flows, "Test Connection" buttons, or rich input controls. Receives `PluginSettingsPageProps` with `context.companyId` set to the active company. The component is responsible for reading and writing config through the bridge (via `usePluginData` and `usePluginAction`).
+
+#### `dashboardWidget`
+
+A card or section rendered on the main dashboard. Use this for at-a-glance metrics, status indicators, or summary views that surface plugin data alongside core Paperclip information. Receives `PluginWidgetProps` with `context.companyId` set to the active company. Requires the `ui.dashboardWidget.register` capability.
+
+#### `detailTab`
+
+An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
+
+#### `taskDetailView`
+
+A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.
+
+#### `projectSidebarItem`
+
+A link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. "Files", "Linear Sync") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin::`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.
+
+#### `toolbarButton`
+
+A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
+
+#### `contextMenuItem`
+
+An entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. "Copy to Linear", "Re-run analysis"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
+
+#### `commentAnnotation`
+
+A per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability.
+
+#### `commentContextMenuItem`
+
+A per-comment context menu item rendered in the "more" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as "Create sub-issue from comment", "Translate", "Flag for review", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability.
+
+### Launcher actions and render options
+
+| Launcher action | Description |
+|-----------------|-------------|
+| `navigate` | Navigate to a route (plugin or host). |
+| `openModal` | Open a modal. |
+| `openDrawer` | Open a drawer. |
+| `openPopover` | Open a popover. |
+| `performAction` | Run an action (e.g. call plugin). |
+| `deepLink` | Deep link to plugin or external URL. |
+
+| Render option | Values | Description |
+|---------------|--------|-------------|
+| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |
+| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |
+
+### Capabilities
+
+Declare in `manifest.capabilities`. Grouped by scope:
+
+| Scope | Capability |
+|-------|------------|
+| **Company** | `companies.read` |
+| | `projects.read` |
+| | `project.workspaces.read` |
+| | `issues.read` |
+| | `issue.comments.read` |
+| | `agents.read` |
+| | `goals.read` |
+| | `goals.create` |
+| | `goals.update` |
+| | `activity.read` |
+| | `costs.read` |
+| | `issues.create` |
+| | `issues.update` |
+| | `issue.comments.create` |
+| | `assets.write` |
+| | `assets.read` |
+| | `activity.log.write` |
+| | `metrics.write` |
+| **Instance** | `instance.settings.register` |
+| | `plugin.state.read` |
+| | `plugin.state.write` |
+| **Runtime** | `events.subscribe` |
+| | `events.emit` |
+| | `jobs.schedule` |
+| | `webhooks.receive` |
+| | `http.outbound` |
+| | `secrets.read-ref` |
+| **Agent** | `agent.tools.register` |
+| | `agents.invoke` |
+| | `agent.sessions.create` |
+| | `agent.sessions.list` |
+| | `agent.sessions.send` |
+| | `agent.sessions.close` |
+| **UI** | `ui.sidebar.register` |
+| | `ui.page.register` |
+| | `ui.detailTab.register` |
+| | `ui.dashboardWidget.register` |
+| | `ui.commentAnnotation.register` |
+| | `ui.action.register` |
+
+Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
+
+## UI quick start
+
+```tsx
+import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui";
+
+export function DashboardWidget() {
+ const { data } = usePluginData<{ status: string }>("health");
+ const ping = usePluginAction("ping");
+ return (
+
+
+ void ping()}>Ping
+
+ );
+}
+```
+
+### Hooks reference
+
+#### `usePluginData(key, params?)`
+
+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";
+
+interface SyncStatus {
+ lastSyncAt: string;
+ syncedCount: number;
+ healthy: boolean;
+}
+
+export function SyncStatusWidget({ context }: PluginWidgetProps) {
+ const { data, loading, error, refresh } = usePluginData("sync-status", {
+ companyId: context.companyId,
+ });
+
+ if (loading) return ;
+ if (error) return ;
+
+ return (
+
+
+
Synced {data!.syncedCount} items
+
Last sync: {data!.lastSyncAt}
+
Refresh
+
+ );
+}
+```
+
+#### `usePluginAction(key)`
+
+Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
+
+```tsx
+import { useState } from "react";
+import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
+
+export function ResyncButton({ context }: PluginWidgetProps) {
+ const resync = usePluginAction("resync");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleClick() {
+ setBusy(true);
+ setError(null);
+ try {
+ await resync({ companyId: context.companyId });
+ } catch (err) {
+ setError((err as PluginBridgeError).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+ {busy ? "Syncing..." : "Resync Now"}
+
+ {error &&
{error}
}
+
+ );
+}
+```
+
+#### `useHostContext()`
+
+Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
+
+```tsx
+import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
+import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
+
+export function IssueLinearLink({ context }: PluginDetailTabProps) {
+ const { companyId, entityId, entityType } = context;
+ const { data } = usePluginData<{ url: string }>("linear-link", {
+ companyId,
+ issueId: entityId,
+ });
+
+ if (!data?.url) return No linked Linear issue.
;
+ return View in Linear ;
+}
+```
+
+#### `usePluginStream(channel, options?)`
+
+Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
+
+```tsx
+import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
+
+interface ChatToken {
+ text: string;
+}
+
+export function ChatMessages({ context }: PluginWidgetProps) {
+ const { events, connected, close } = usePluginStream("chat-stream", {
+ companyId: context.companyId ?? undefined,
+ });
+
+ return (
+
+ {events.map((e, i) => {e.text} )}
+ {connected && }
+ Stop
+
+ );
+}
+```
+
+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
+
+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
+
+
+```
+
+#### `StatusBadge`
+
+Inline status indicator with semantic color.
+
+```tsx
+
+
+
+```
+
+#### `DataTable`
+
+Sortable, paginated table.
+
+```tsx
+ 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
+
+```
+
+#### `ActionBar`
+
+Row of action buttons wired to the plugin bridge.
+
+```tsx
+ data.refresh()}
+ onError={(key, err) => console.error(key, err)}
+/>
+```
+
+#### `LogView`, `JsonTree`, `KeyValueList`, `MarkdownBlock`
+
+```tsx
+
+
+
+
+```
+
+#### `Spinner`, `ErrorBoundary`
+
+```tsx
+
+
+Something went wrong.
} onError={(err) => console.error(err)}>
+
+
+```
+
+### Slot component props
+
+Each slot type receives a typed props object with `context: PluginHostContext`. Import from `@paperclipai/plugin-sdk/ui`.
+
+| Slot type | Props interface | `context` extras |
+|-----------|----------------|------------------|
+| `page` | `PluginPageProps` | — |
+| `sidebar` | `PluginSidebarProps` | — |
+| `settingsPage` | `PluginSettingsPageProps` | — |
+| `dashboardWidget` | `PluginWidgetProps` | — |
+| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
+| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
+| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
+| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
+
+Example detail tab with entity context:
+
+```tsx
+import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
+import { usePluginData, KeyValueList, Spinner } from "@paperclipai/plugin-sdk/ui";
+
+export function AgentMetricsTab({ context }: PluginDetailTabProps) {
+ const { data, loading } = usePluginData>("agent-metrics", {
+ agentId: context.entityId,
+ companyId: context.companyId,
+ });
+
+ if (loading) return ;
+ if (!data) return No metrics available.
;
+
+ return (
+ ({ label, value }))}
+ />
+ );
+}
+```
+
+## Launcher surfaces and modals
+
+V1 does not provide a dedicated `modal` slot. Plugins can either:
+
+- declare concrete UI mount points in `ui.slots`
+- declare host-rendered entry points in `ui.launchers`
+
+Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
+
+Declarative launcher example:
+
+```json
+{
+ "ui": {
+ "launchers": [
+ {
+ "id": "sync-project",
+ "displayName": "Sync",
+ "placementZone": "toolbarButton",
+ "entityTypes": ["project"],
+ "action": {
+ "type": "openDrawer",
+ "target": "sync-project"
+ },
+ "render": {
+ "environment": "hostOverlay",
+ "bounds": "wide"
+ }
+ }
+ ]
+ }
+}
+```
+
+The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.
+
+When a launcher opens a host-owned overlay or page, `useHostContext()`,
+`usePluginData()`, and `usePluginAction()` receive the current
+`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.
+full-page layouts without adding custom route parsing in the plugin.
+
+## Project sidebar item
+
+Plugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in `context.entityId`. Declare the slot and capability in your manifest:
+
+```json
+{
+ "ui": {
+ "slots": [
+ {
+ "type": "projectSidebarItem",
+ "id": "files",
+ "displayName": "Files",
+ "exportName": "FilesLink",
+ "entityTypes": ["project"]
+ }
+ ]
+ },
+ "capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
+}
+```
+
+Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
+
+```tsx
+import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
+
+export function FilesLink({ context }: PluginProjectSidebarItemProps) {
+ const projectId = context.entityId;
+ const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
+ const projectRef = projectId; // or resolve from host; entityId is project id
+ return (
+
+ Files
+
+ );
+}
+```
+
+Use optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow.
+
+## Toolbar launcher with a local modal
+
+For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project.
+
+```json
+{
+ "ui": {
+ "slots": [
+ {
+ "type": "toolbarButton",
+ "id": "sync-toolbar-button",
+ "displayName": "Sync",
+ "exportName": "SyncToolbarButton"
+ }
+ ]
+ },
+ "capabilities": ["ui.action.register"]
+}
+```
+
+```tsx
+import { useState } from "react";
+import {
+ ErrorBoundary,
+ Spinner,
+ useHostContext,
+ usePluginAction,
+} from "@paperclipai/plugin-sdk/ui";
+
+export function SyncToolbarButton() {
+ const context = useHostContext();
+ const syncProject = usePluginAction("sync-project");
+ const [open, setOpen] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ async function confirm() {
+ if (!context.projectId) return;
+ setSubmitting(true);
+ setErrorMessage(null);
+ try {
+ await syncProject({ projectId: context.projectId });
+ setOpen(false);
+ } catch (err) {
+ setErrorMessage(err instanceof Error ? err.message : "Sync failed");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ setOpen(true)}>
+ Sync
+
+ {open ? (
+ !submitting && setOpen(false)}
+ >
+
event.stopPropagation()}
+ >
+
Sync this project?
+
+ Queue a sync for {context.projectId}.
+
+ {errorMessage ? (
+
{errorMessage}
+ ) : null}
+
+ setOpen(false)}>
+ Cancel
+
+ void confirm()} disabled={submitting}>
+ {submitting ? : "Run sync"}
+
+
+
+
+ ) : null}
+
+ );
+}
+```
+
+Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
+
+## Real-time streaming (`ctx.streams`)
+
+Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.
+
+### Worker side
+
+In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
+
+```ts
+const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.actions.register("chat", async (params) => {
+ const companyId = params.companyId as string;
+ ctx.streams.open("chat-stream", companyId);
+
+ for await (const token of streamFromLLM(params.prompt as string)) {
+ ctx.streams.emit("chat-stream", { text: token });
+ }
+
+ ctx.streams.close("chat-stream");
+ return { ok: true };
+ });
+ },
+});
+```
+
+**API:**
+
+| Method | Description |
+|--------|-------------|
+| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
+| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
+| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
+
+Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
+
+### UI side
+
+Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
+
+### Host-side architecture
+
+The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
+
+1. Worker emits `streams.emit` notification via stdout
+2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
+3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
+
+The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
+
+### Streaming agent responses to the UI
+
+`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
+
+```
+UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
+UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
+```
+
+The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
+
+**Worker:**
+
+```ts
+ctx.actions.register("ask-agent", async (params) => {
+ const { agentId, companyId, prompt } = params as {
+ agentId: string; companyId: string; prompt: string;
+ };
+
+ const channel = `agent:${agentId}`;
+ ctx.streams.open(channel, companyId);
+
+ const session = await ctx.agents.sessions.create(agentId, companyId);
+
+ await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
+ prompt,
+ onEvent: (event) => {
+ ctx.streams.emit(channel, {
+ type: event.eventType, // "chunk" | "done" | "error"
+ text: event.message ?? "",
+ });
+ },
+ });
+
+ ctx.streams.close(channel);
+ return { sessionId: session.sessionId };
+});
+```
+
+**UI:**
+
+```tsx
+import { useState } from "react";
+import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
+
+interface AgentEvent {
+ type: "chunk" | "done" | "error";
+ text: string;
+}
+
+export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
+ const askAgent = usePluginAction("ask-agent");
+ const { events, connected, close } = usePluginStream(`agent:${agentId}`, { companyId });
+ const [prompt, setPrompt] = useState("");
+
+ async function send() {
+ setPrompt("");
+ await askAgent({ agentId, companyId, prompt });
+ }
+
+ return (
+
+
{events.filter(e => e.type === "chunk").map((e, i) => {e.text} )}
+
setPrompt(e.target.value)} />
+
Send
+ {connected &&
Stop }
+
+ );
+}
+```
+
+## Agent sessions (two-way chat)
+
+Plugins can hold multi-turn conversational sessions with agents:
+
+```ts
+// Create a session
+const session = await ctx.agents.sessions.create(agentId, companyId);
+
+// Send a message and stream the response
+await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
+ prompt: "Help me triage this issue",
+ onEvent: (event) => {
+ if (event.eventType === "chunk") console.log(event.message);
+ if (event.eventType === "done") console.log("Stream complete");
+ },
+});
+
+// List active sessions
+const sessions = await ctx.agents.sessions.list(agentId, companyId);
+
+// Close when done
+await ctx.agents.sessions.close(session.sessionId, companyId);
+```
+
+Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
+
+Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
+
+## Testing utilities
+
+```ts
+import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
+import plugin from "../src/worker.js";
+import manifest from "../src/manifest.js";
+
+const harness = createTestHarness({ manifest });
+await plugin.definition.setup(harness.ctx);
+await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
+```
+
+## Bundler presets
+
+```ts
+import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
+
+const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
+// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
+// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
+```
+
+## Local dev server (hot-reload events)
+
+```bash
+paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
+```
+
+Or programmatically:
+
+```ts
+import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
+const server = await startPluginDevServer({ rootDir: process.cwd() });
+```
+
+Dev server endpoints:
+- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
+- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json
new file mode 100644
index 00000000..d5e5c19c
--- /dev/null
+++ b/packages/plugins/sdk/package.json
@@ -0,0 +1,124 @@
+{
+ "name": "@paperclipai/plugin-sdk",
+ "version": "1.0.0",
+ "description": "Stable public API for Paperclip plugins — worker-side context and UI bridge hooks",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./protocol": {
+ "types": "./dist/protocol.d.ts",
+ "import": "./dist/protocol.js"
+ },
+ "./types": {
+ "types": "./dist/types.d.ts",
+ "import": "./dist/types.js"
+ },
+ "./ui": {
+ "types": "./dist/ui/index.d.ts",
+ "import": "./dist/ui/index.js"
+ },
+ "./ui/hooks": {
+ "types": "./dist/ui/hooks.d.ts",
+ "import": "./dist/ui/hooks.js"
+ },
+ "./ui/types": {
+ "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"
+ },
+ "./bundlers": {
+ "types": "./dist/bundlers.d.ts",
+ "import": "./dist/bundlers.js"
+ },
+ "./dev-server": {
+ "types": "./dist/dev-server.d.ts",
+ "import": "./dist/dev-server.js"
+ }
+ },
+ "bin": {
+ "paperclip-plugin-dev-server": "./dist/dev-cli.js"
+ },
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./protocol": {
+ "types": "./dist/protocol.d.ts",
+ "import": "./dist/protocol.js"
+ },
+ "./types": {
+ "types": "./dist/types.d.ts",
+ "import": "./dist/types.js"
+ },
+ "./ui": {
+ "types": "./dist/ui/index.d.ts",
+ "import": "./dist/ui/index.js"
+ },
+ "./ui/hooks": {
+ "types": "./dist/ui/hooks.d.ts",
+ "import": "./dist/ui/hooks.js"
+ },
+ "./ui/types": {
+ "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"
+ },
+ "./bundlers": {
+ "types": "./dist/bundlers.d.ts",
+ "import": "./dist/bundlers.js"
+ },
+ "./dev-server": {
+ "types": "./dist/dev-server.d.ts",
+ "import": "./dist/dev-server.js"
+ }
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "pnpm --filter @paperclipai/shared build && tsc",
+ "clean": "rm -rf dist",
+ "typecheck": "pnpm --filter @paperclipai/shared build && tsc --noEmit",
+ "dev:server": "tsx src/dev-cli.ts"
+ },
+ "dependencies": {
+ "@paperclipai/shared": "workspace:*",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "@types/node": "^24.6.0",
+ "@types/react": "^19.0.8",
+ "typescript": "^5.7.3"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+}
diff --git a/packages/plugins/sdk/src/bundlers.ts b/packages/plugins/sdk/src/bundlers.ts
new file mode 100644
index 00000000..a8ec6850
--- /dev/null
+++ b/packages/plugins/sdk/src/bundlers.ts
@@ -0,0 +1,161 @@
+/**
+ * Bundling presets for Paperclip plugins.
+ *
+ * These helpers return plain config objects so plugin authors can use them
+ * with esbuild or rollup without re-implementing host contract defaults.
+ */
+
+export interface PluginBundlerPresetInput {
+ pluginRoot?: string;
+ manifestEntry?: string;
+ workerEntry?: string;
+ uiEntry?: string;
+ outdir?: string;
+ sourcemap?: boolean;
+ minify?: boolean;
+}
+
+export interface EsbuildLikeOptions {
+ entryPoints: string[];
+ outdir: string;
+ bundle: boolean;
+ format: "esm";
+ platform: "node" | "browser";
+ target: string;
+ sourcemap?: boolean;
+ minify?: boolean;
+ external?: string[];
+}
+
+export interface RollupLikeConfig {
+ input: string;
+ output: {
+ dir: string;
+ format: "es";
+ sourcemap?: boolean;
+ entryFileNames?: string;
+ };
+ external?: string[];
+ plugins?: unknown[];
+}
+
+export interface PluginBundlerPresets {
+ esbuild: {
+ worker: EsbuildLikeOptions;
+ ui?: EsbuildLikeOptions;
+ manifest: EsbuildLikeOptions;
+ };
+ rollup: {
+ worker: RollupLikeConfig;
+ ui?: RollupLikeConfig;
+ manifest: RollupLikeConfig;
+ };
+}
+
+/**
+ * Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles.
+ *
+ * The presets intentionally externalize host/runtime deps (`react`, SDK packages)
+ * to match the Paperclip plugin loader contract.
+ */
+export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets {
+ const uiExternal = [
+ "@paperclipai/plugin-sdk/ui",
+ "@paperclipai/plugin-sdk/ui/hooks",
+ "@paperclipai/plugin-sdk/ui/components",
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ ];
+
+ const outdir = input.outdir ?? "dist";
+ const workerEntry = input.workerEntry ?? "src/worker.ts";
+ const manifestEntry = input.manifestEntry ?? "src/manifest.ts";
+ const uiEntry = input.uiEntry;
+ const sourcemap = input.sourcemap ?? true;
+ const minify = input.minify ?? false;
+
+ const esbuildWorker: EsbuildLikeOptions = {
+ entryPoints: [workerEntry],
+ outdir,
+ bundle: true,
+ format: "esm",
+ platform: "node",
+ target: "node20",
+ sourcemap,
+ minify,
+ external: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "react", "react-dom"],
+ };
+
+ const esbuildManifest: EsbuildLikeOptions = {
+ entryPoints: [manifestEntry],
+ outdir,
+ bundle: false,
+ format: "esm",
+ platform: "node",
+ target: "node20",
+ sourcemap,
+ };
+
+ const esbuildUi = uiEntry
+ ? {
+ entryPoints: [uiEntry],
+ outdir: `${outdir}/ui`,
+ bundle: true,
+ format: "esm" as const,
+ platform: "browser" as const,
+ target: "es2022",
+ sourcemap,
+ minify,
+ external: uiExternal,
+ }
+ : undefined;
+
+ const rollupWorker: RollupLikeConfig = {
+ input: workerEntry,
+ output: {
+ dir: outdir,
+ format: "es",
+ sourcemap,
+ entryFileNames: "worker.js",
+ },
+ external: ["@paperclipai/plugin-sdk", "react", "react-dom"],
+ };
+
+ const rollupManifest: RollupLikeConfig = {
+ input: manifestEntry,
+ output: {
+ dir: outdir,
+ format: "es",
+ sourcemap,
+ entryFileNames: "manifest.js",
+ },
+ external: ["@paperclipai/plugin-sdk"],
+ };
+
+ const rollupUi = uiEntry
+ ? {
+ input: uiEntry,
+ output: {
+ dir: `${outdir}/ui`,
+ format: "es" as const,
+ sourcemap,
+ entryFileNames: "index.js",
+ },
+ external: uiExternal,
+ }
+ : undefined;
+
+ return {
+ esbuild: {
+ worker: esbuildWorker,
+ manifest: esbuildManifest,
+ ...(esbuildUi ? { ui: esbuildUi } : {}),
+ },
+ rollup: {
+ worker: rollupWorker,
+ manifest: rollupManifest,
+ ...(rollupUi ? { ui: rollupUi } : {}),
+ },
+ };
+}
diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts
new file mode 100644
index 00000000..43fefdd2
--- /dev/null
+++ b/packages/plugins/sdk/src/define-plugin.ts
@@ -0,0 +1,255 @@
+/**
+ * `definePlugin` — the top-level helper for authoring a Paperclip plugin.
+ *
+ * Plugin authors call `definePlugin()` and export the result as the default
+ * export from their worker entrypoint. The host imports the worker module,
+ * calls `setup()` with a `PluginContext`, and from that point the plugin
+ * responds to events, jobs, webhooks, and UI requests through the context.
+ *
+ * @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
+ *
+ * @example
+ * ```ts
+ * // dist/worker.ts
+ * import { definePlugin } from "@paperclipai/plugin-sdk";
+ *
+ * export default definePlugin({
+ * async setup(ctx) {
+ * ctx.logger.info("Linear sync plugin starting");
+ *
+ * // Subscribe to events
+ * ctx.events.on("issue.created", async (event) => {
+ * const config = await ctx.config.get();
+ * await ctx.http.fetch(`https://api.linear.app/...`, {
+ * method: "POST",
+ * headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
+ * body: JSON.stringify({ title: event.payload.title }),
+ * });
+ * });
+ *
+ * // Register a job handler
+ * ctx.jobs.register("full-sync", async (job) => {
+ * ctx.logger.info("Running full-sync job", { runId: job.runId });
+ * // ... sync logic
+ * });
+ *
+ * // Register data for the UI
+ * ctx.data.register("sync-health", async ({ companyId }) => {
+ * const state = await ctx.state.get({
+ * scopeKind: "company",
+ * scopeId: String(companyId),
+ * stateKey: "last-sync",
+ * });
+ * return { lastSync: state };
+ * });
+ * },
+ * });
+ * ```
+ */
+
+import type { PluginContext } from "./types.js";
+
+// ---------------------------------------------------------------------------
+// Health check result
+// ---------------------------------------------------------------------------
+
+/**
+ * Optional plugin-reported diagnostics returned from the `health()` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.2 — `health`
+ */
+export interface PluginHealthDiagnostics {
+ /** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
+ status: "ok" | "degraded" | "error";
+ /** Human-readable description of the current health state. */
+ message?: string;
+ /** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
+ details?: Record;
+}
+
+// ---------------------------------------------------------------------------
+// Config validation result
+// ---------------------------------------------------------------------------
+
+/**
+ * Result returned from the `validateConfig()` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.3 — `validateConfig`
+ */
+export interface PluginConfigValidationResult {
+ /** Whether the config is valid. */
+ ok: boolean;
+ /** Non-fatal warnings about the config. */
+ warnings?: string[];
+ /** Validation errors (populated when `ok` is `false`). */
+ errors?: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Webhook handler input
+// ---------------------------------------------------------------------------
+
+/**
+ * Input received by the plugin worker's `handleWebhook` handler.
+ *
+ * @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
+ */
+export interface PluginWebhookInput {
+ /** Endpoint key matching the manifest declaration. */
+ endpointKey: string;
+ /** Inbound request headers. */
+ headers: Record;
+ /** Raw request body as a UTF-8 string. */
+ rawBody: string;
+ /** Parsed JSON body (if applicable and parseable). */
+ parsedBody?: unknown;
+ /** Unique request identifier for idempotency checks. */
+ requestId: string;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin definition
+// ---------------------------------------------------------------------------
+
+/**
+ * The plugin definition shape passed to `definePlugin()`.
+ *
+ * The only required field is `setup`, which receives the `PluginContext` and
+ * is where the plugin registers its handlers (events, jobs, data, actions,
+ * tools, etc.).
+ *
+ * All other lifecycle hooks are optional. If a hook is not implemented the
+ * host applies default behaviour (e.g. restarting the worker on config change
+ * instead of calling `onConfigChanged`).
+ *
+ * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
+ */
+export interface PluginDefinition {
+ /**
+ * Called once when the plugin worker starts up, after `initialize` completes.
+ *
+ * This is where the plugin registers all its handlers: event subscriptions,
+ * job handlers, data/action handlers, and tool registrations. Registration
+ * must be synchronous after `setup` resolves — do not register handlers
+ * inside async callbacks that may resolve after `setup` returns.
+ *
+ * @param ctx - The full plugin context provided by the host
+ */
+ setup(ctx: PluginContext): Promise;
+
+ /**
+ * Called when the host wants to know if the plugin is healthy.
+ *
+ * The host polls this on a regular interval and surfaces the result in the
+ * plugin health dashboard. If not implemented, the host infers health from
+ * worker process liveness.
+ *
+ * @see PLUGIN_SPEC.md §13.2 — `health`
+ */
+ onHealth?(): Promise;
+
+ /**
+ * Called when the operator updates the plugin's instance configuration at
+ * runtime, without restarting the worker.
+ *
+ * If not implemented, the host restarts the worker to apply the new config.
+ *
+ * @param newConfig - The newly resolved configuration
+ * @see PLUGIN_SPEC.md §13.4 — `configChanged`
+ */
+ onConfigChanged?(newConfig: Record): Promise;
+
+ /**
+ * Called when the host is about to shut down the plugin worker.
+ *
+ * The worker has at most 10 seconds (configurable via plugin config) to
+ * finish in-flight work and resolve this promise. After the deadline the
+ * host sends SIGTERM, then SIGKILL.
+ *
+ * @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
+ */
+ onShutdown?(): Promise;
+
+ /**
+ * Called to validate the current plugin configuration.
+ *
+ * The host calls this:
+ * - after the plugin starts (to surface config errors immediately)
+ * - after the operator saves a new config (to validate before persisting)
+ * - via the "Test Connection" button in the settings UI
+ *
+ * @param config - The configuration to validate
+ * @see PLUGIN_SPEC.md §13.3 — `validateConfig`
+ */
+ onValidateConfig?(config: Record): Promise;
+
+ /**
+ * Called to handle an inbound webhook delivery.
+ *
+ * The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
+ * this handler. The plugin is responsible for signature verification using
+ * a resolved secret ref.
+ *
+ * If not implemented but webhooks are declared in the manifest, the host
+ * returns HTTP 501 for webhook deliveries.
+ *
+ * @param input - Webhook delivery metadata and payload
+ * @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
+ */
+ onWebhook?(input: PluginWebhookInput): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// PaperclipPlugin — the sealed object returned by definePlugin()
+// ---------------------------------------------------------------------------
+
+/**
+ * The sealed plugin object returned by `definePlugin()`.
+ *
+ * Plugin authors export this as the default export from their worker
+ * entrypoint. The host imports it and calls the lifecycle methods.
+ *
+ * @see PLUGIN_SPEC.md §14 — SDK Surface
+ */
+export interface PaperclipPlugin {
+ /** The original plugin definition passed to `definePlugin()`. */
+ readonly definition: PluginDefinition;
+}
+
+// ---------------------------------------------------------------------------
+// definePlugin — top-level factory
+// ---------------------------------------------------------------------------
+
+/**
+ * Define a Paperclip plugin.
+ *
+ * Call this function in your worker entrypoint and export the result as the
+ * default export. The host will import the module and call lifecycle methods
+ * on the returned object.
+ *
+ * @param definition - Plugin lifecycle handlers
+ * @returns A sealed `PaperclipPlugin` object for the host to consume
+ *
+ * @example
+ * ```ts
+ * import { definePlugin } from "@paperclipai/plugin-sdk";
+ *
+ * export default definePlugin({
+ * async setup(ctx) {
+ * ctx.logger.info("Plugin started");
+ * ctx.events.on("issue.created", async (event) => {
+ * // handle event
+ * });
+ * },
+ *
+ * async onHealth() {
+ * return { status: "ok" };
+ * },
+ * });
+ * ```
+ *
+ * @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
+ */
+export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
+ return Object.freeze({ definition });
+}
diff --git a/packages/plugins/sdk/src/dev-cli.ts b/packages/plugins/sdk/src/dev-cli.ts
new file mode 100644
index 00000000..7c1b0e2b
--- /dev/null
+++ b/packages/plugins/sdk/src/dev-cli.ts
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+import path from "node:path";
+import { startPluginDevServer } from "./dev-server.js";
+
+function parseArg(flag: string): string | undefined {
+ const index = process.argv.indexOf(flag);
+ if (index < 0) return undefined;
+ return process.argv[index + 1];
+}
+
+/**
+ * CLI entrypoint for the local plugin UI preview server.
+ *
+ * This is intentionally minimal and delegates all serving behavior to
+ * `startPluginDevServer` so tests and programmatic usage share one path.
+ */
+async function main() {
+ const rootDir = parseArg("--root") ?? process.cwd();
+ const uiDir = parseArg("--ui-dir") ?? "dist/ui";
+ const host = parseArg("--host") ?? "127.0.0.1";
+ const rawPort = parseArg("--port") ?? "4177";
+ const port = Number.parseInt(rawPort, 10);
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
+ throw new Error(`Invalid --port value: ${rawPort}`);
+ }
+
+ const server = await startPluginDevServer({
+ rootDir: path.resolve(rootDir),
+ uiDir,
+ host,
+ port,
+ });
+
+ // eslint-disable-next-line no-console
+ console.log(`Paperclip plugin dev server listening at ${server.url}`);
+
+ const shutdown = async () => {
+ await server.close();
+ process.exit(0);
+ };
+
+ process.on("SIGINT", () => {
+ void shutdown();
+ });
+ process.on("SIGTERM", () => {
+ void shutdown();
+ });
+}
+
+void main().catch((error) => {
+ // eslint-disable-next-line no-console
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});
diff --git a/packages/plugins/sdk/src/dev-server.ts b/packages/plugins/sdk/src/dev-server.ts
new file mode 100644
index 00000000..2eadff81
--- /dev/null
+++ b/packages/plugins/sdk/src/dev-server.ts
@@ -0,0 +1,228 @@
+import { createReadStream, existsSync, statSync, watch } from "node:fs";
+import { mkdir, readdir, stat } from "node:fs/promises";
+import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
+import type { AddressInfo } from "node:net";
+import path from "node:path";
+
+export interface PluginDevServerOptions {
+ /** Plugin project root. Defaults to `process.cwd()`. */
+ rootDir?: string;
+ /** Relative path from root to built UI assets. Defaults to `dist/ui`. */
+ uiDir?: string;
+ /** Bind port for local preview server. Defaults to `4177`. */
+ port?: number;
+ /** Bind host. Defaults to `127.0.0.1`. */
+ host?: string;
+}
+
+export interface PluginDevServer {
+ url: string;
+ close(): Promise;
+}
+
+interface Closeable {
+ close(): void;
+}
+
+function contentType(filePath: string): string {
+ if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
+ if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
+ if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
+ if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
+ if (filePath.endsWith(".svg")) return "image/svg+xml";
+ return "application/octet-stream";
+}
+
+function normalizeFilePath(baseDir: string, reqPath: string): string {
+ const pathname = reqPath.split("?")[0] || "/";
+ const resolved = pathname === "/" ? "/index.js" : pathname;
+ const absolute = path.resolve(baseDir, `.${resolved}`);
+ const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
+ if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
+ throw new Error("path traversal blocked");
+ }
+ return absolute;
+}
+
+function send404(res: ServerResponse) {
+ res.statusCode = 404;
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify({ error: "Not found" }));
+}
+
+function sendJson(res: ServerResponse, value: unknown) {
+ res.statusCode = 200;
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
+ res.end(JSON.stringify(value));
+}
+
+async function ensureUiDir(uiDir: string): Promise {
+ if (existsSync(uiDir)) return;
+ await mkdir(uiDir, { recursive: true });
+}
+
+async function listFilesRecursive(dir: string): Promise {
+ const out: string[] = [];
+ const entries = await readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const abs = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...await listFilesRecursive(abs));
+ } else if (entry.isFile()) {
+ out.push(abs);
+ }
+ }
+ return out;
+}
+
+function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
+ return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
+}
+
+async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise {
+ try {
+ // macOS/Windows support recursive native watching.
+ const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
+ if (!filename) return;
+ onReload(path.join(uiDir, filename));
+ });
+ return watcher;
+ } catch {
+ // Linux may reject recursive watch. Fall back to polling snapshots.
+ let previous = snapshotSignature(
+ (await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
+ file: row.file,
+ mtimeMs: row.mtimeMs,
+ })),
+ );
+
+ const timer = setInterval(async () => {
+ try {
+ const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
+ const next = snapshotSignature(nextRows);
+ if (next === previous) return;
+ previous = next;
+ onReload("__snapshot__");
+ } catch {
+ // Ignore transient read errors while bundlers are writing files.
+ }
+ }, 500);
+
+ return {
+ close() {
+ clearInterval(timer);
+ },
+ };
+ }
+}
+
+/**
+ * Start a local static server for plugin UI assets with SSE reload events.
+ *
+ * Endpoint summary:
+ * - `GET /__paperclip__/health` for diagnostics
+ * - `GET /__paperclip__/events` for hot-reload stream
+ * - Any other path serves files from the configured UI build directory
+ */
+export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise {
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
+ const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
+ const host = options.host ?? "127.0.0.1";
+ const port = options.port ?? 4177;
+
+ await ensureUiDir(uiDir);
+
+ const sseClients = new Set();
+
+ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
+ const url = req.url ?? "/";
+
+ if (url === "/__paperclip__/health") {
+ sendJson(res, { ok: true, rootDir, uiDir });
+ return;
+ }
+
+ if (url === "/__paperclip__/events") {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ });
+ res.write(`event: connected\ndata: {"ok":true}\n\n`);
+ sseClients.add(res);
+ req.on("close", () => {
+ sseClients.delete(res);
+ });
+ return;
+ }
+
+ try {
+ const filePath = normalizeFilePath(uiDir, url);
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
+ send404(res);
+ return;
+ }
+
+ res.statusCode = 200;
+ res.setHeader("Content-Type", contentType(filePath));
+ createReadStream(filePath).pipe(res);
+ } catch {
+ send404(res);
+ }
+ };
+
+ const server = createServer((req, res) => {
+ void handleRequest(req, res);
+ });
+
+ const notifyReload = (filePath: string) => {
+ const rel = path.relative(uiDir, filePath);
+ const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
+ for (const client of sseClients) {
+ client.write(`event: reload\ndata: ${payload}\n\n`);
+ }
+ };
+
+ const watcher = await startUiWatcher(uiDir, notifyReload);
+
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(port, host, () => resolve());
+ });
+
+ const address = server.address();
+ const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
+
+ return {
+ url: `http://${host}:${actualPort}`,
+ async close() {
+ watcher.close();
+ for (const client of sseClients) {
+ client.end();
+ }
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+ },
+ };
+}
+
+/**
+ * Return a stable file+mtime snapshot for a built plugin UI directory.
+ *
+ * Used by the polling watcher fallback and useful for tests that need to assert
+ * whether a UI build has changed between runs.
+ */
+export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise> {
+ const baseDir = path.resolve(rootDir, uiDir);
+ if (!existsSync(baseDir)) return [];
+ const files = await listFilesRecursive(baseDir);
+ const rows = await Promise.all(files.map(async (filePath) => {
+ const fileStat = await stat(filePath);
+ return {
+ file: path.relative(baseDir, filePath),
+ mtimeMs: fileStat.mtimeMs,
+ };
+ }));
+ return rows.sort((a, b) => a.file.localeCompare(b.file));
+}
diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts
new file mode 100644
index 00000000..82d2b42f
--- /dev/null
+++ b/packages/plugins/sdk/src/host-client-factory.ts
@@ -0,0 +1,563 @@
+/**
+ * Host-side client factory — creates capability-gated handler maps for
+ * servicing worker→host JSON-RPC calls.
+ *
+ * When a plugin worker calls `ctx.state.get(...)` inside its process, the
+ * SDK serializes the call as a JSON-RPC request over stdio. On the host side,
+ * the `PluginWorkerManager` receives the request and dispatches it to the
+ * handler registered for that method. This module provides a factory that
+ * creates those handlers for all `WorkerToHostMethods`, with automatic
+ * capability enforcement.
+ *
+ * ## Design
+ *
+ * 1. **Capability gating**: Each handler checks the plugin's declared
+ * capabilities before executing. If the plugin lacks a required capability,
+ * the handler throws a `CapabilityDeniedError` (which the worker manager
+ * translates into a JSON-RPC error response with code
+ * `CAPABILITY_DENIED`).
+ *
+ * 2. **Service adapters**: The caller provides a `HostServices` object with
+ * concrete implementations of each platform service. The factory wires
+ * each handler to the appropriate service method.
+ *
+ * 3. **Type safety**: The returned handler map is typed as
+ * `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs
+ * directly into `WorkerStartOptions.hostHandlers`.
+ *
+ * @example
+ * ```ts
+ * const handlers = createHostClientHandlers({
+ * pluginId: "acme.linear",
+ * capabilities: manifest.capabilities,
+ * services: {
+ * config: { get: () => registry.getConfig(pluginId) },
+ * state: { get: ..., set: ..., delete: ... },
+ * entities: { upsert: ..., list: ... },
+ * // ... all services
+ * },
+ * });
+ *
+ * await workerManager.startWorker("acme.linear", {
+ * // ...
+ * hostHandlers: handlers,
+ * });
+ * ```
+ *
+ * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
+ * @see PLUGIN_SPEC.md §15 — Capability Model
+ */
+
+import type { PluginCapability } from "@paperclipai/shared";
+import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
+import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
+
+// ---------------------------------------------------------------------------
+// Error types
+// ---------------------------------------------------------------------------
+
+/**
+ * Thrown when a plugin calls a host method it does not have the capability for.
+ *
+ * The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so
+ * the worker manager can propagate it as the correct JSON-RPC error code.
+ */
+export class CapabilityDeniedError extends Error {
+ override readonly name = "CapabilityDeniedError";
+ readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
+
+ constructor(pluginId: string, method: string, capability: PluginCapability) {
+ super(
+ `Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`,
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Host service interfaces
+// ---------------------------------------------------------------------------
+
+/**
+ * Service adapters that the host must provide. Each property maps to a group
+ * of `WorkerToHostMethods`. The factory wires JSON-RPC params to these
+ * function signatures.
+ *
+ * All methods return promises to support async I/O (database, HTTP, etc.).
+ */
+export interface HostServices {
+ /** Provides `config.get`. */
+ config: {
+ get(): Promise>;
+ };
+
+ /** Provides `state.get`, `state.set`, `state.delete`. */
+ state: {
+ get(params: WorkerToHostMethods["state.get"][0]): Promise;
+ set(params: WorkerToHostMethods["state.set"][0]): Promise;
+ delete(params: WorkerToHostMethods["state.delete"][0]): Promise;
+ };
+
+ /** Provides `entities.upsert`, `entities.list`. */
+ entities: {
+ upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise;
+ list(params: WorkerToHostMethods["entities.list"][0]): Promise;
+ };
+
+ /** Provides `events.emit`. */
+ events: {
+ emit(params: WorkerToHostMethods["events.emit"][0]): Promise;
+ };
+
+ /** Provides `http.fetch`. */
+ http: {
+ fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise;
+ };
+
+ /** Provides `secrets.resolve`. */
+ secrets: {
+ resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise;
+ };
+
+ /** Provides `assets.upload`, `assets.getUrl`. */
+ assets: {
+ upload(params: WorkerToHostMethods["assets.upload"][0]): Promise;
+ getUrl(params: WorkerToHostMethods["assets.getUrl"][0]): Promise;
+ };
+
+ /** Provides `activity.log`. */
+ activity: {
+ log(params: {
+ companyId: string;
+ message: string;
+ entityType?: string;
+ entityId?: string;
+ metadata?: Record;
+ }): Promise;
+ };
+
+ /** Provides `metrics.write`. */
+ metrics: {
+ write(params: WorkerToHostMethods["metrics.write"][0]): Promise;
+ };
+
+ /** Provides `log`. */
+ logger: {
+ log(params: WorkerToHostMethods["log"][0]): Promise;
+ };
+
+ /** Provides `companies.list`, `companies.get`. */
+ companies: {
+ list(params: WorkerToHostMethods["companies.list"][0]): Promise;
+ get(params: WorkerToHostMethods["companies.get"][0]): Promise;
+ };
+
+ /** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
+ projects: {
+ list(params: WorkerToHostMethods["projects.list"][0]): Promise;
+ get(params: WorkerToHostMethods["projects.get"][0]): Promise;
+ listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise;
+ getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise;
+ getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise;
+ };
+
+ /** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
+ issues: {
+ list(params: WorkerToHostMethods["issues.list"][0]): Promise;
+ get(params: WorkerToHostMethods["issues.get"][0]): Promise;
+ create(params: WorkerToHostMethods["issues.create"][0]): Promise;
+ update(params: WorkerToHostMethods["issues.update"][0]): Promise;
+ listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise;
+ createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise;
+ };
+
+ /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
+ agents: {
+ list(params: WorkerToHostMethods["agents.list"][0]): Promise;
+ get(params: WorkerToHostMethods["agents.get"][0]): Promise;
+ pause(params: WorkerToHostMethods["agents.pause"][0]): Promise;
+ resume(params: WorkerToHostMethods["agents.resume"][0]): Promise;
+ invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise;
+ };
+
+ /** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
+ agentSessions: {
+ create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise;
+ list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise;
+ sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise;
+ close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise;
+ };
+
+ /** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
+ goals: {
+ list(params: WorkerToHostMethods["goals.list"][0]): Promise;
+ get(params: WorkerToHostMethods["goals.get"][0]): Promise;
+ create(params: WorkerToHostMethods["goals.create"][0]): Promise;
+ update(params: WorkerToHostMethods["goals.update"][0]): Promise;
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Factory input
+// ---------------------------------------------------------------------------
+
+/**
+ * Options for `createHostClientHandlers`.
+ */
+export interface HostClientFactoryOptions {
+ /** The plugin ID. Used for error messages and logging. */
+ pluginId: string;
+
+ /**
+ * The capabilities declared by the plugin in its manifest. The factory
+ * enforces these at runtime before delegating to the service adapter.
+ */
+ capabilities: readonly PluginCapability[];
+
+ /**
+ * Concrete implementations of host platform services. Each handler in the
+ * returned map delegates to the corresponding service method.
+ */
+ services: HostServices;
+}
+
+// ---------------------------------------------------------------------------
+// Handler map type (compatible with WorkerToHostHandlers from worker manager)
+// ---------------------------------------------------------------------------
+
+/**
+ * A handler function for a specific worker→host method.
+ */
+type HostHandler = (
+ params: WorkerToHostMethods[M][0],
+) => Promise;
+
+/**
+ * A complete map of all worker→host method handlers.
+ *
+ * This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
+ * but makes every handler required (the factory always provides all handlers).
+ */
+export type HostClientHandlers = {
+ [M in WorkerToHostMethodName]: HostHandler;
+};
+
+// ---------------------------------------------------------------------------
+// Capability → method mapping
+// ---------------------------------------------------------------------------
+
+/**
+ * Maps each worker→host RPC method to the capability required to invoke it.
+ * Methods without a capability requirement (e.g. `config.get`, `log`) are
+ * mapped to `null`.
+ *
+ * @see PLUGIN_SPEC.md §15 — Capability Model
+ */
+const METHOD_CAPABILITY_MAP: Record = {
+ // Config — always allowed
+ "config.get": null,
+
+ // State
+ "state.get": "plugin.state.read",
+ "state.set": "plugin.state.write",
+ "state.delete": "plugin.state.write",
+
+ // Entities — no specific capability required (plugin-scoped by design)
+ "entities.upsert": null,
+ "entities.list": null,
+
+ // Events
+ "events.emit": "events.emit",
+
+ // HTTP
+ "http.fetch": "http.outbound",
+
+ // Secrets
+ "secrets.resolve": "secrets.read-ref",
+
+ // Assets
+ "assets.upload": "assets.write",
+ "assets.getUrl": "assets.read",
+
+ // Activity
+ "activity.log": "activity.log.write",
+
+ // Metrics
+ "metrics.write": "metrics.write",
+
+ // Logger — always allowed
+ "log": null,
+
+ // Companies
+ "companies.list": "companies.read",
+ "companies.get": "companies.read",
+
+ // Projects
+ "projects.list": "projects.read",
+ "projects.get": "projects.read",
+ "projects.listWorkspaces": "project.workspaces.read",
+ "projects.getPrimaryWorkspace": "project.workspaces.read",
+ "projects.getWorkspaceForIssue": "project.workspaces.read",
+
+ // Issues
+ "issues.list": "issues.read",
+ "issues.get": "issues.read",
+ "issues.create": "issues.create",
+ "issues.update": "issues.update",
+ "issues.listComments": "issue.comments.read",
+ "issues.createComment": "issue.comments.create",
+
+ // Agents
+ "agents.list": "agents.read",
+ "agents.get": "agents.read",
+ "agents.pause": "agents.pause",
+ "agents.resume": "agents.resume",
+ "agents.invoke": "agents.invoke",
+
+ // Agent Sessions
+ "agents.sessions.create": "agent.sessions.create",
+ "agents.sessions.list": "agent.sessions.list",
+ "agents.sessions.sendMessage": "agent.sessions.send",
+ "agents.sessions.close": "agent.sessions.close",
+
+ // Goals
+ "goals.list": "goals.read",
+ "goals.get": "goals.read",
+ "goals.create": "goals.create",
+ "goals.update": "goals.update",
+};
+
+// ---------------------------------------------------------------------------
+// Factory
+// ---------------------------------------------------------------------------
+
+/**
+ * Create a complete handler map for all worker→host JSON-RPC methods.
+ *
+ * Each handler:
+ * 1. Checks the plugin's declared capabilities against the required capability
+ * for the method (if any).
+ * 2. Delegates to the corresponding service adapter method.
+ * 3. Returns the service result, which is serialized as the JSON-RPC response
+ * by the worker manager.
+ *
+ * If a capability check fails, the handler throws a `CapabilityDeniedError`
+ * with code `CAPABILITY_DENIED`. The worker manager catches this and sends a
+ * JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError`
+ * in the plugin's SDK client.
+ *
+ * @param options - Plugin ID, capabilities, and service adapters
+ * @returns A handler map suitable for `WorkerStartOptions.hostHandlers`
+ */
+export function createHostClientHandlers(
+ options: HostClientFactoryOptions,
+): HostClientHandlers {
+ const { pluginId, services } = options;
+ const capabilitySet = new Set(options.capabilities);
+
+ /**
+ * Assert that the plugin has the required capability for a method.
+ * Throws `CapabilityDeniedError` if the capability is missing.
+ */
+ function requireCapability(
+ method: WorkerToHostMethodName,
+ ): void {
+ const required = METHOD_CAPABILITY_MAP[method];
+ if (required === null) return; // No capability required
+ if (capabilitySet.has(required)) return;
+ throw new CapabilityDeniedError(pluginId, method, required);
+ }
+
+ /**
+ * Create a capability-gated proxy handler for a method.
+ *
+ * @param method - The RPC method name (used for capability lookup)
+ * @param handler - The actual handler implementation
+ * @returns A wrapper that checks capabilities before delegating
+ */
+ function gated(
+ method: M,
+ handler: HostHandler,
+ ): HostHandler {
+ return async (params: WorkerToHostMethods[M][0]) => {
+ requireCapability(method);
+ return handler(params);
+ };
+ }
+
+ // -------------------------------------------------------------------------
+ // Build the complete handler map
+ // -------------------------------------------------------------------------
+
+ return {
+ // Config
+ "config.get": gated("config.get", async () => {
+ return services.config.get();
+ }),
+
+ // State
+ "state.get": gated("state.get", async (params) => {
+ return services.state.get(params);
+ }),
+ "state.set": gated("state.set", async (params) => {
+ return services.state.set(params);
+ }),
+ "state.delete": gated("state.delete", async (params) => {
+ return services.state.delete(params);
+ }),
+
+ // Entities
+ "entities.upsert": gated("entities.upsert", async (params) => {
+ return services.entities.upsert(params);
+ }),
+ "entities.list": gated("entities.list", async (params) => {
+ return services.entities.list(params);
+ }),
+
+ // Events
+ "events.emit": gated("events.emit", async (params) => {
+ return services.events.emit(params);
+ }),
+
+ // HTTP
+ "http.fetch": gated("http.fetch", async (params) => {
+ return services.http.fetch(params);
+ }),
+
+ // Secrets
+ "secrets.resolve": gated("secrets.resolve", async (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.log": gated("activity.log", async (params) => {
+ return services.activity.log(params);
+ }),
+
+ // Metrics
+ "metrics.write": gated("metrics.write", async (params) => {
+ return services.metrics.write(params);
+ }),
+
+ // Logger
+ "log": gated("log", async (params) => {
+ return services.logger.log(params);
+ }),
+
+ // Companies
+ "companies.list": gated("companies.list", async (params) => {
+ return services.companies.list(params);
+ }),
+ "companies.get": gated("companies.get", async (params) => {
+ return services.companies.get(params);
+ }),
+
+ // Projects
+ "projects.list": gated("projects.list", async (params) => {
+ return services.projects.list(params);
+ }),
+ "projects.get": gated("projects.get", async (params) => {
+ return services.projects.get(params);
+ }),
+ "projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => {
+ return services.projects.listWorkspaces(params);
+ }),
+ "projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => {
+ return services.projects.getPrimaryWorkspace(params);
+ }),
+ "projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
+ return services.projects.getWorkspaceForIssue(params);
+ }),
+
+ // Issues
+ "issues.list": gated("issues.list", async (params) => {
+ return services.issues.list(params);
+ }),
+ "issues.get": gated("issues.get", async (params) => {
+ return services.issues.get(params);
+ }),
+ "issues.create": gated("issues.create", async (params) => {
+ return services.issues.create(params);
+ }),
+ "issues.update": gated("issues.update", async (params) => {
+ return services.issues.update(params);
+ }),
+ "issues.listComments": gated("issues.listComments", async (params) => {
+ return services.issues.listComments(params);
+ }),
+ "issues.createComment": gated("issues.createComment", async (params) => {
+ return services.issues.createComment(params);
+ }),
+
+ // Agents
+ "agents.list": gated("agents.list", async (params) => {
+ return services.agents.list(params);
+ }),
+ "agents.get": gated("agents.get", async (params) => {
+ return services.agents.get(params);
+ }),
+ "agents.pause": gated("agents.pause", async (params) => {
+ return services.agents.pause(params);
+ }),
+ "agents.resume": gated("agents.resume", async (params) => {
+ return services.agents.resume(params);
+ }),
+ "agents.invoke": gated("agents.invoke", async (params) => {
+ return services.agents.invoke(params);
+ }),
+
+ // Agent Sessions
+ "agents.sessions.create": gated("agents.sessions.create", async (params) => {
+ return services.agentSessions.create(params);
+ }),
+ "agents.sessions.list": gated("agents.sessions.list", async (params) => {
+ return services.agentSessions.list(params);
+ }),
+ "agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => {
+ return services.agentSessions.sendMessage(params);
+ }),
+ "agents.sessions.close": gated("agents.sessions.close", async (params) => {
+ return services.agentSessions.close(params);
+ }),
+
+ // Goals
+ "goals.list": gated("goals.list", async (params) => {
+ return services.goals.list(params);
+ }),
+ "goals.get": gated("goals.get", async (params) => {
+ return services.goals.get(params);
+ }),
+ "goals.create": gated("goals.create", async (params) => {
+ return services.goals.create(params);
+ }),
+ "goals.update": gated("goals.update", async (params) => {
+ return services.goals.update(params);
+ }),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Utility: getRequiredCapability
+// ---------------------------------------------------------------------------
+
+/**
+ * Get the capability required for a given worker→host method, or `null` if
+ * no capability is required.
+ *
+ * Useful for inspecting capability requirements without calling the factory.
+ *
+ * @param method - The worker→host method name
+ * @returns The required capability, or `null`
+ */
+export function getRequiredCapability(
+ method: WorkerToHostMethodName,
+): PluginCapability | null {
+ return METHOD_CAPABILITY_MAP[method];
+}
diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts
new file mode 100644
index 00000000..60c59f6a
--- /dev/null
+++ b/packages/plugins/sdk/src/index.ts
@@ -0,0 +1,287 @@
+/**
+ * `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK.
+ *
+ * This is the main entrypoint for plugin worker code. For plugin UI bundles,
+ * import from `@paperclipai/plugin-sdk/ui` instead.
+ *
+ * @example
+ * ```ts
+ * // Plugin worker entrypoint (dist/worker.ts)
+ * import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk";
+ *
+ * const plugin = definePlugin({
+ * async setup(ctx) {
+ * ctx.logger.info("Plugin starting up");
+ *
+ * ctx.events.on("issue.created", async (event) => {
+ * ctx.logger.info("Issue created", { issueId: event.entityId });
+ * });
+ *
+ * ctx.jobs.register("full-sync", async (job) => {
+ * ctx.logger.info("Starting full sync", { runId: job.runId });
+ * // ... sync implementation
+ * });
+ *
+ * ctx.data.register("sync-health", async ({ companyId }) => {
+ * const state = await ctx.state.get({
+ * scopeKind: "company",
+ * scopeId: String(companyId),
+ * stateKey: "last-sync-at",
+ * });
+ * return { lastSync: state };
+ * });
+ * },
+ *
+ * async onHealth() {
+ * return { status: "ok" };
+ * },
+ * });
+ *
+ * export default plugin;
+ * runWorker(plugin, import.meta.url);
+ * ```
+ *
+ * @see PLUGIN_SPEC.md §14 — SDK Surface
+ * @see PLUGIN_SPEC.md §29.2 — SDK Versioning
+ */
+
+// ---------------------------------------------------------------------------
+// Main factory
+// ---------------------------------------------------------------------------
+
+export { definePlugin } from "./define-plugin.js";
+export { createTestHarness } from "./testing.js";
+export { createPluginBundlerPresets } from "./bundlers.js";
+export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
+export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
+export {
+ createHostClientHandlers,
+ getRequiredCapability,
+ CapabilityDeniedError,
+} from "./host-client-factory.js";
+
+// JSON-RPC protocol helpers and constants
+export {
+ JSONRPC_VERSION,
+ JSONRPC_ERROR_CODES,
+ PLUGIN_RPC_ERROR_CODES,
+ HOST_TO_WORKER_REQUIRED_METHODS,
+ HOST_TO_WORKER_OPTIONAL_METHODS,
+ MESSAGE_DELIMITER,
+ createRequest,
+ createSuccessResponse,
+ createErrorResponse,
+ createNotification,
+ isJsonRpcRequest,
+ isJsonRpcNotification,
+ isJsonRpcResponse,
+ isJsonRpcSuccessResponse,
+ isJsonRpcErrorResponse,
+ serializeMessage,
+ parseMessage,
+ JsonRpcParseError,
+ JsonRpcCallError,
+ _resetIdCounter,
+} from "./protocol.js";
+
+// ---------------------------------------------------------------------------
+// Type exports
+// ---------------------------------------------------------------------------
+
+// Plugin definition and lifecycle types
+export type {
+ PluginDefinition,
+ PaperclipPlugin,
+ PluginHealthDiagnostics,
+ PluginConfigValidationResult,
+ PluginWebhookInput,
+} from "./define-plugin.js";
+export type {
+ TestHarness,
+ TestHarnessOptions,
+ TestHarnessLogEntry,
+} from "./testing.js";
+export type {
+ PluginBundlerPresetInput,
+ PluginBundlerPresets,
+ EsbuildLikeOptions,
+ RollupLikeConfig,
+} from "./bundlers.js";
+export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js";
+export type {
+ WorkerRpcHostOptions,
+ WorkerRpcHost,
+ RunWorkerOptions,
+} from "./worker-rpc-host.js";
+export type {
+ HostServices,
+ HostClientFactoryOptions,
+ HostClientHandlers,
+} from "./host-client-factory.js";
+
+// JSON-RPC protocol types
+export type {
+ JsonRpcId,
+ JsonRpcRequest,
+ JsonRpcSuccessResponse,
+ JsonRpcError,
+ JsonRpcErrorResponse,
+ JsonRpcResponse,
+ JsonRpcNotification,
+ JsonRpcMessage,
+ JsonRpcErrorCode,
+ PluginRpcErrorCode,
+ InitializeParams,
+ InitializeResult,
+ ConfigChangedParams,
+ ValidateConfigParams,
+ OnEventParams,
+ RunJobParams,
+ GetDataParams,
+ PerformActionParams,
+ ExecuteToolParams,
+ PluginModalBoundsRequest,
+ PluginRenderCloseEvent,
+ PluginLauncherRenderContextSnapshot,
+ HostToWorkerMethods,
+ HostToWorkerMethodName,
+ WorkerToHostMethods,
+ WorkerToHostMethodName,
+ HostToWorkerRequest,
+ HostToWorkerResponse,
+ WorkerToHostRequest,
+ WorkerToHostResponse,
+ WorkerToHostNotifications,
+ WorkerToHostNotificationName,
+} from "./protocol.js";
+
+// Plugin context and all client interfaces
+export type {
+ PluginContext,
+ PluginConfigClient,
+ PluginEventsClient,
+ PluginJobsClient,
+ PluginLaunchersClient,
+ PluginHttpClient,
+ PluginSecretsClient,
+ PluginAssetsClient,
+ PluginActivityClient,
+ PluginActivityLogEntry,
+ PluginStateClient,
+ PluginEntitiesClient,
+ PluginProjectsClient,
+ PluginCompaniesClient,
+ PluginIssuesClient,
+ PluginAgentsClient,
+ PluginAgentSessionsClient,
+ AgentSession,
+ AgentSessionEvent,
+ AgentSessionSendResult,
+ PluginGoalsClient,
+ PluginDataClient,
+ PluginActionsClient,
+ PluginStreamsClient,
+ PluginToolsClient,
+ PluginMetricsClient,
+ PluginLogger,
+} from "./types.js";
+
+// Supporting types for context clients
+export type {
+ ScopeKey,
+ EventFilter,
+ PluginEvent,
+ PluginJobContext,
+ PluginLauncherRegistration,
+ ToolRunContext,
+ ToolResult,
+ PluginEntityUpsert,
+ PluginEntityRecord,
+ PluginEntityQuery,
+ PluginWorkspace,
+ Company,
+ Project,
+ Issue,
+ IssueComment,
+ Agent,
+ Goal,
+} from "./types.js";
+
+// Manifest and constant types re-exported from @paperclipai/shared
+// Plugin authors import manifest types from here so they have a single
+// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs.
+export type {
+ PaperclipPluginManifestV1,
+ PluginJobDeclaration,
+ PluginWebhookDeclaration,
+ PluginToolDeclaration,
+ PluginUiSlotDeclaration,
+ PluginUiDeclaration,
+ PluginLauncherActionDeclaration,
+ PluginLauncherRenderDeclaration,
+ PluginLauncherDeclaration,
+ PluginMinimumHostVersion,
+ PluginRecord,
+ PluginConfig,
+ JsonSchema,
+ PluginStatus,
+ PluginCategory,
+ PluginCapability,
+ PluginUiSlotType,
+ PluginUiSlotEntityType,
+ PluginLauncherPlacementZone,
+ PluginLauncherAction,
+ PluginLauncherBounds,
+ PluginLauncherRenderEnvironment,
+ PluginStateScopeKind,
+ PluginJobStatus,
+ PluginJobRunStatus,
+ PluginJobRunTrigger,
+ PluginWebhookDeliveryStatus,
+ PluginEventType,
+ PluginBridgeErrorCode,
+} from "./types.js";
+
+// ---------------------------------------------------------------------------
+// Zod re-export
+// ---------------------------------------------------------------------------
+
+/**
+ * Zod is re-exported for plugin authors to use when defining their
+ * `instanceConfigSchema` and tool `parametersSchema`.
+ *
+ * Plugin authors do not need to add a separate `zod` dependency.
+ *
+ * @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
+ *
+ * @example
+ * ```ts
+ * import { z } from "@paperclipai/plugin-sdk";
+ *
+ * const configSchema = z.object({
+ * apiKey: z.string().describe("Your API key"),
+ * workspace: z.string().optional(),
+ * });
+ * ```
+ */
+export { z } from "zod";
+
+// ---------------------------------------------------------------------------
+// Constants re-exports (for plugin code that needs to check values at runtime)
+// ---------------------------------------------------------------------------
+
+export {
+ PLUGIN_API_VERSION,
+ PLUGIN_STATUSES,
+ PLUGIN_CATEGORIES,
+ PLUGIN_CAPABILITIES,
+ PLUGIN_UI_SLOT_TYPES,
+ PLUGIN_UI_SLOT_ENTITY_TYPES,
+ PLUGIN_STATE_SCOPE_KINDS,
+ PLUGIN_JOB_STATUSES,
+ PLUGIN_JOB_RUN_STATUSES,
+ PLUGIN_JOB_RUN_TRIGGERS,
+ PLUGIN_WEBHOOK_DELIVERY_STATUSES,
+ PLUGIN_EVENT_TYPES,
+ PLUGIN_BRIDGE_ERROR_CODES,
+} from "@paperclipai/shared";
diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts
new file mode 100644
index 00000000..211f2573
--- /dev/null
+++ b/packages/plugins/sdk/src/protocol.ts
@@ -0,0 +1,1038 @@
+/**
+ * JSON-RPC 2.0 message types and protocol helpers for the host ↔ worker IPC
+ * channel.
+ *
+ * The Paperclip plugin runtime uses JSON-RPC 2.0 over stdio to communicate
+ * between the host process and each plugin worker process. This module defines:
+ *
+ * - Core JSON-RPC 2.0 envelope types (request, response, notification, error)
+ * - Standard and plugin-specific error codes
+ * - Typed method maps for host→worker and worker→host calls
+ * - Helper functions for creating well-formed messages
+ *
+ * @see PLUGIN_SPEC.md §12.1 — Process Model
+ * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
+ * @see https://www.jsonrpc.org/specification
+ */
+
+import type {
+ PaperclipPluginManifestV1,
+ PluginLauncherBounds,
+ PluginLauncherRenderContextSnapshot,
+ PluginLauncherRenderEnvironment,
+ PluginStateScopeKind,
+ Company,
+ Project,
+ Issue,
+ IssueComment,
+ Agent,
+ Goal,
+} from "@paperclipai/shared";
+export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
+
+import type {
+ PluginEvent,
+ PluginJobContext,
+ PluginWorkspace,
+ ToolRunContext,
+ ToolResult,
+} from "./types.js";
+import type {
+ PluginHealthDiagnostics,
+ PluginConfigValidationResult,
+ PluginWebhookInput,
+} from "./define-plugin.js";
+
+// ---------------------------------------------------------------------------
+// JSON-RPC 2.0 — Core Protocol Types
+// ---------------------------------------------------------------------------
+
+/** The JSON-RPC protocol version. Always `"2.0"`. */
+export const JSONRPC_VERSION = "2.0" as const;
+
+/**
+ * A unique request identifier. JSON-RPC 2.0 allows strings or numbers;
+ * we use strings (UUIDs or monotonic counters) for all Paperclip messages.
+ */
+export type JsonRpcId = string | number;
+
+/**
+ * A JSON-RPC 2.0 request message.
+ *
+ * The host sends requests to the worker (or vice versa) and expects a
+ * matching response with the same `id`.
+ */
+export interface JsonRpcRequest<
+ TMethod extends string = string,
+ TParams = unknown,
+> {
+ readonly jsonrpc: typeof JSONRPC_VERSION;
+ /** Unique request identifier. Must be echoed in the response. */
+ readonly id: JsonRpcId;
+ /** The RPC method name to invoke. */
+ readonly method: TMethod;
+ /** Structured parameters for the method call. */
+ readonly params: TParams;
+}
+
+/**
+ * A JSON-RPC 2.0 success response.
+ */
+export interface JsonRpcSuccessResponse {
+ readonly jsonrpc: typeof JSONRPC_VERSION;
+ /** Echoed request identifier. */
+ readonly id: JsonRpcId;
+ /** The method return value. */
+ readonly result: TResult;
+ readonly error?: never;
+}
+
+/**
+ * A JSON-RPC 2.0 error object embedded in an error response.
+ */
+export interface JsonRpcError {
+ /** Machine-readable error code. */
+ readonly code: number;
+ /** Human-readable error message. */
+ readonly message: string;
+ /** Optional structured error data. */
+ readonly data?: TData;
+}
+
+/**
+ * A JSON-RPC 2.0 error response.
+ */
+export interface JsonRpcErrorResponse {
+ readonly jsonrpc: typeof JSONRPC_VERSION;
+ /** Echoed request identifier. */
+ readonly id: JsonRpcId | null;
+ readonly result?: never;
+ /** The error object. */
+ readonly error: JsonRpcError;
+}
+
+/**
+ * A JSON-RPC 2.0 response — either success or error.
+ */
+export type JsonRpcResponse =
+ | JsonRpcSuccessResponse
+ | JsonRpcErrorResponse;
+
+/**
+ * A JSON-RPC 2.0 notification (a request with no `id`).
+ *
+ * Notifications are fire-and-forget — no response is expected.
+ */
+export interface JsonRpcNotification<
+ TMethod extends string = string,
+ TParams = unknown,
+> {
+ readonly jsonrpc: typeof JSONRPC_VERSION;
+ readonly id?: never;
+ /** The notification method name. */
+ readonly method: TMethod;
+ /** Structured parameters for the notification. */
+ readonly params: TParams;
+}
+
+/**
+ * Any well-formed JSON-RPC 2.0 message (request, response, or notification).
+ */
+export type JsonRpcMessage =
+ | JsonRpcRequest
+ | JsonRpcResponse
+ | JsonRpcNotification;
+
+// ---------------------------------------------------------------------------
+// Error Codes
+// ---------------------------------------------------------------------------
+
+/**
+ * Standard JSON-RPC 2.0 error codes.
+ *
+ * @see https://www.jsonrpc.org/specification#error_object
+ */
+export const JSONRPC_ERROR_CODES = {
+ /** Invalid JSON was received by the server. */
+ PARSE_ERROR: -32700,
+ /** The JSON sent is not a valid Request object. */
+ INVALID_REQUEST: -32600,
+ /** The method does not exist or is not available. */
+ METHOD_NOT_FOUND: -32601,
+ /** Invalid method parameter(s). */
+ INVALID_PARAMS: -32602,
+ /** Internal JSON-RPC error. */
+ INTERNAL_ERROR: -32603,
+} as const;
+
+export type JsonRpcErrorCode =
+ (typeof JSONRPC_ERROR_CODES)[keyof typeof JSONRPC_ERROR_CODES];
+
+/**
+ * Paperclip plugin-specific error codes.
+ *
+ * These live in the JSON-RPC "server error" reserved range (-32000 to -32099)
+ * as specified by JSON-RPC 2.0 for implementation-defined server errors.
+ *
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
+ */
+export const PLUGIN_RPC_ERROR_CODES = {
+ /** The worker process is not running or not reachable. */
+ WORKER_UNAVAILABLE: -32000,
+ /** The plugin does not have the required capability for this operation. */
+ CAPABILITY_DENIED: -32001,
+ /** The worker reported an unhandled error during method execution. */
+ WORKER_ERROR: -32002,
+ /** The method call timed out waiting for the worker response. */
+ TIMEOUT: -32003,
+ /** The worker does not implement the requested optional method. */
+ METHOD_NOT_IMPLEMENTED: -32004,
+ /** A catch-all for errors that do not fit other categories. */
+ UNKNOWN: -32099,
+} as const;
+
+export type PluginRpcErrorCode =
+ (typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES];
+
+// ---------------------------------------------------------------------------
+// Host → Worker Method Signatures (§13 Host-Worker Protocol)
+// ---------------------------------------------------------------------------
+
+/**
+ * Input for the `initialize` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.1 — `initialize`
+ */
+export interface InitializeParams {
+ /** Full plugin manifest snapshot. */
+ manifest: PaperclipPluginManifestV1;
+ /** Resolved operator configuration (validated against `instanceConfigSchema`). */
+ config: Record;
+ /** Instance-level metadata. */
+ instanceInfo: {
+ /** UUID of this Paperclip instance. */
+ instanceId: string;
+ /** Semver version of the running Paperclip host. */
+ hostVersion: string;
+ };
+ /** Host API version. */
+ apiVersion: number;
+}
+
+/**
+ * Result returned by the `initialize` RPC method.
+ */
+export interface InitializeResult {
+ /** Whether initialization succeeded. */
+ ok: boolean;
+ /** Optional methods the worker has implemented (e.g. "validateConfig", "onEvent"). */
+ supportedMethods?: string[];
+}
+
+/**
+ * Input for the `configChanged` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.4 — `configChanged`
+ */
+export interface ConfigChangedParams {
+ /** The newly resolved configuration. */
+ config: Record;
+}
+
+/**
+ * Input for the `validateConfig` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.3 — `validateConfig`
+ */
+export interface ValidateConfigParams {
+ /** The configuration to validate. */
+ config: Record;
+}
+
+/**
+ * Input for the `onEvent` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.5 — `onEvent`
+ */
+export interface OnEventParams {
+ /** The domain event to deliver. */
+ event: PluginEvent;
+}
+
+/**
+ * Input for the `runJob` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.6 — `runJob`
+ */
+export interface RunJobParams {
+ /** Job execution context. */
+ job: PluginJobContext;
+}
+
+/**
+ * Input for the `getData` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.8 — `getData`
+ */
+export interface GetDataParams {
+ /** Plugin-defined data key (e.g. `"sync-health"`). */
+ key: string;
+ /** Context and query parameters from the UI. */
+ params: Record;
+ /** Optional launcher/container metadata from the host render environment. */
+ renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
+}
+
+/**
+ * Input for the `performAction` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.9 — `performAction`
+ */
+export interface PerformActionParams {
+ /** Plugin-defined action key (e.g. `"resync"`). */
+ key: string;
+ /** Action parameters from the UI. */
+ params: Record;
+ /** Optional launcher/container metadata from the host render environment. */
+ renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
+}
+
+/**
+ * Input for the `executeTool` RPC method.
+ *
+ * @see PLUGIN_SPEC.md §13.10 — `executeTool`
+ */
+export interface ExecuteToolParams {
+ /** Tool name (without plugin namespace prefix). */
+ toolName: string;
+ /** Parsed parameters matching the tool's declared schema. */
+ parameters: unknown;
+ /** Agent run context. */
+ runContext: ToolRunContext;
+}
+
+// ---------------------------------------------------------------------------
+// UI launcher / modal host interaction payloads
+// ---------------------------------------------------------------------------
+
+/**
+ * Bounds request issued by a plugin UI running inside a host-managed launcher
+ * container such as a modal, drawer, or popover.
+ */
+export interface PluginModalBoundsRequest {
+ /** High-level size preset requested from the host. */
+ bounds: PluginLauncherBounds;
+ /** Optional explicit width override in CSS pixels. */
+ width?: number;
+ /** Optional explicit height override in CSS pixels. */
+ height?: number;
+ /** Optional lower bounds for host resizing decisions. */
+ minWidth?: number;
+ minHeight?: number;
+ /** Optional upper bounds for host resizing decisions. */
+ maxWidth?: number;
+ maxHeight?: number;
+}
+
+/**
+ * Reason metadata supplied by host-managed close lifecycle callbacks.
+ */
+export interface PluginRenderCloseEvent {
+ reason:
+ | "escapeKey"
+ | "backdrop"
+ | "hostNavigation"
+ | "programmatic"
+ | "submit"
+ | "unknown";
+ nativeEvent?: unknown;
+}
+
+/**
+ * Map of host→worker RPC method names to their `[params, result]` types.
+ *
+ * This type is the single source of truth for all methods the host can call
+ * on a worker. Used by both the host dispatcher and the worker handler to
+ * ensure type safety across the IPC boundary.
+ */
+export interface HostToWorkerMethods {
+ /** @see PLUGIN_SPEC.md §13.1 */
+ initialize: [params: InitializeParams, result: InitializeResult];
+ /** @see PLUGIN_SPEC.md §13.2 */
+ health: [params: Record, result: PluginHealthDiagnostics];
+ /** @see PLUGIN_SPEC.md §12.5 */
+ shutdown: [params: Record, result: void];
+ /** @see PLUGIN_SPEC.md §13.3 */
+ validateConfig: [params: ValidateConfigParams, result: PluginConfigValidationResult];
+ /** @see PLUGIN_SPEC.md §13.4 */
+ configChanged: [params: ConfigChangedParams, result: void];
+ /** @see PLUGIN_SPEC.md §13.5 */
+ onEvent: [params: OnEventParams, result: void];
+ /** @see PLUGIN_SPEC.md §13.6 */
+ runJob: [params: RunJobParams, result: void];
+ /** @see PLUGIN_SPEC.md §13.7 */
+ handleWebhook: [params: PluginWebhookInput, result: void];
+ /** @see PLUGIN_SPEC.md §13.8 */
+ getData: [params: GetDataParams, result: unknown];
+ /** @see PLUGIN_SPEC.md §13.9 */
+ performAction: [params: PerformActionParams, result: unknown];
+ /** @see PLUGIN_SPEC.md §13.10 */
+ executeTool: [params: ExecuteToolParams, result: ToolResult];
+}
+
+/** Union of all host→worker method names. */
+export type HostToWorkerMethodName = keyof HostToWorkerMethods;
+
+/** Required methods the worker MUST implement. */
+export const HOST_TO_WORKER_REQUIRED_METHODS: readonly HostToWorkerMethodName[] = [
+ "initialize",
+ "health",
+ "shutdown",
+] as const;
+
+/** Optional methods the worker MAY implement. */
+export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] = [
+ "validateConfig",
+ "configChanged",
+ "onEvent",
+ "runJob",
+ "handleWebhook",
+ "getData",
+ "performAction",
+ "executeTool",
+] as const;
+
+// ---------------------------------------------------------------------------
+// Worker → Host Method Signatures (SDK client calls)
+// ---------------------------------------------------------------------------
+
+/**
+ * Map of worker→host RPC method names to their `[params, result]` types.
+ *
+ * These represent the SDK client calls that the worker makes back to the
+ * host to access platform services (state, entities, config, etc.).
+ */
+export interface WorkerToHostMethods {
+ // Config
+ "config.get": [params: Record, result: Record];
+
+ // State
+ "state.get": [
+ params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
+ result: unknown,
+ ];
+ "state.set": [
+ params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string; value: unknown },
+ result: void,
+ ];
+ "state.delete": [
+ params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
+ result: void,
+ ];
+
+ // Entities
+ "entities.upsert": [
+ params: {
+ entityType: string;
+ scopeKind: PluginStateScopeKind;
+ scopeId?: string;
+ externalId?: string;
+ title?: string;
+ status?: string;
+ data: Record;
+ },
+ result: {
+ id: string;
+ entityType: string;
+ scopeKind: PluginStateScopeKind;
+ scopeId: string | null;
+ externalId: string | null;
+ title: string | null;
+ status: string | null;
+ data: Record;
+ createdAt: string;
+ updatedAt: string;
+ },
+ ];
+ "entities.list": [
+ params: {
+ entityType?: string;
+ scopeKind?: PluginStateScopeKind;
+ scopeId?: string;
+ externalId?: string;
+ limit?: number;
+ offset?: number;
+ },
+ result: Array<{
+ id: string;
+ entityType: string;
+ scopeKind: PluginStateScopeKind;
+ scopeId: string | null;
+ externalId: string | null;
+ title: string | null;
+ status: string | null;
+ data: Record;
+ createdAt: string;
+ updatedAt: string;
+ }>,
+ ];
+
+ // Events
+ "events.emit": [
+ params: { name: string; companyId: string; payload: unknown },
+ result: void,
+ ];
+
+ // HTTP
+ "http.fetch": [
+ params: { url: string; init?: Record },
+ result: { status: number; statusText: string; headers: Record; body: string },
+ ];
+
+ // Secrets
+ "secrets.resolve": [
+ params: { secretRef: 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.log": [
+ params: {
+ companyId: string;
+ message: string;
+ entityType?: string;
+ entityId?: string;
+ metadata?: Record;
+ },
+ result: void,
+ ];
+
+ // Metrics
+ "metrics.write": [
+ params: { name: string; value: number; tags?: Record },
+ result: void,
+ ];
+
+ // Logger
+ "log": [
+ params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record },
+ result: void,
+ ];
+
+ // Companies (read)
+ "companies.list": [
+ params: { limit?: number; offset?: number },
+ result: Company[],
+ ];
+ "companies.get": [
+ params: { companyId: string },
+ result: Company | null,
+ ];
+
+ // Projects (read)
+ "projects.list": [
+ params: { companyId: string; limit?: number; offset?: number },
+ result: Project[],
+ ];
+ "projects.get": [
+ params: { projectId: string; companyId: string },
+ result: Project | null,
+ ];
+ "projects.listWorkspaces": [
+ params: { projectId: string; companyId: string },
+ result: PluginWorkspace[],
+ ];
+ "projects.getPrimaryWorkspace": [
+ params: { projectId: string; companyId: string },
+ result: PluginWorkspace | null,
+ ];
+ "projects.getWorkspaceForIssue": [
+ params: { issueId: string; companyId: string },
+ result: PluginWorkspace | null,
+ ];
+
+ // Issues
+ "issues.list": [
+ params: {
+ companyId: string;
+ projectId?: string;
+ assigneeAgentId?: string;
+ status?: string;
+ limit?: number;
+ offset?: number;
+ },
+ result: Issue[],
+ ];
+ "issues.get": [
+ params: { issueId: string; companyId: string },
+ result: Issue | null,
+ ];
+ "issues.create": [
+ params: {
+ companyId: string;
+ projectId?: string;
+ goalId?: string;
+ parentId?: string;
+ title: string;
+ description?: string;
+ priority?: string;
+ assigneeAgentId?: string;
+ },
+ result: Issue,
+ ];
+ "issues.update": [
+ params: {
+ issueId: string;
+ patch: Record;
+ companyId: string;
+ },
+ result: Issue,
+ ];
+ "issues.listComments": [
+ params: { issueId: string; companyId: string },
+ result: IssueComment[],
+ ];
+ "issues.createComment": [
+ params: { issueId: string; body: string; companyId: string },
+ result: IssueComment,
+ ];
+
+ // Agents (read)
+ "agents.list": [
+ params: { companyId: string; status?: string; limit?: number; offset?: number },
+ result: Agent[],
+ ];
+ "agents.get": [
+ params: { agentId: string; companyId: string },
+ result: Agent | null,
+ ];
+
+ // Agents (write)
+ "agents.pause": [
+ params: { agentId: string; companyId: string },
+ result: Agent,
+ ];
+ "agents.resume": [
+ params: { agentId: string; companyId: string },
+ result: Agent,
+ ];
+ "agents.invoke": [
+ params: { agentId: string; companyId: string; prompt: string; reason?: string },
+ result: { runId: string },
+ ];
+
+ // Agent Sessions
+ "agents.sessions.create": [
+ params: { agentId: string; companyId: string; taskKey?: string; reason?: string },
+ result: { sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string },
+ ];
+ "agents.sessions.list": [
+ params: { agentId: string; companyId: string },
+ result: Array<{ sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string }>,
+ ];
+ "agents.sessions.sendMessage": [
+ params: { sessionId: string; companyId: string; prompt: string; reason?: string },
+ result: { runId: string },
+ ];
+ "agents.sessions.close": [
+ params: { sessionId: string; companyId: string },
+ result: void,
+ ];
+
+ // Goals
+ "goals.list": [
+ params: { companyId: string; level?: string; status?: string; limit?: number; offset?: number },
+ result: Goal[],
+ ];
+ "goals.get": [
+ params: { goalId: string; companyId: string },
+ result: Goal | null,
+ ];
+ "goals.create": [
+ params: {
+ companyId: string;
+ title: string;
+ description?: string;
+ level?: string;
+ status?: string;
+ parentId?: string;
+ ownerAgentId?: string;
+ },
+ result: Goal,
+ ];
+ "goals.update": [
+ params: {
+ goalId: string;
+ patch: Record;
+ companyId: string;
+ },
+ result: Goal,
+ ];
+}
+
+/** Union of all worker→host method names. */
+export type WorkerToHostMethodName = keyof WorkerToHostMethods;
+
+// ---------------------------------------------------------------------------
+// Worker→Host Notification Types (fire-and-forget, no response)
+// ---------------------------------------------------------------------------
+
+/**
+ * Typed parameter shapes for worker→host JSON-RPC notifications.
+ *
+ * Notifications are fire-and-forget — the worker does not wait for a response.
+ * These are used for streaming events and logging, not for request-response RPCs.
+ */
+export interface WorkerToHostNotifications {
+ /**
+ * Forward a stream event to connected SSE clients.
+ *
+ * Emitted by the worker for each event on a stream channel. The host
+ * publishes to the PluginStreamBus, which fans out to all SSE clients
+ * subscribed to the (pluginId, channel, companyId) tuple.
+ *
+ * The `event` payload is JSON-serializable and sent as SSE `data:`.
+ * The default SSE event type is `"message"`.
+ */
+ "streams.emit": {
+ channel: string;
+ companyId: string;
+ event: unknown;
+ };
+
+ /**
+ * Signal that a stream channel has been opened.
+ *
+ * Emitted when the worker calls `ctx.streams.open(channel, companyId)`.
+ * UI clients may use this to display a "connected" indicator or begin
+ * buffering input. The host tracks open channels so it can emit synthetic
+ * close events if the worker crashes.
+ */
+ "streams.open": {
+ channel: string;
+ companyId: string;
+ };
+
+ /**
+ * Signal that a stream channel has been closed.
+ *
+ * Emitted when the worker calls `ctx.streams.close(channel)`, or
+ * synthetically by the host when a worker process exits with channels
+ * still open. UI clients should treat this as terminal and disconnect
+ * the SSE connection.
+ */
+ "streams.close": {
+ channel: string;
+ companyId: string;
+ };
+}
+
+/** Union of all worker→host notification method names. */
+export type WorkerToHostNotificationName = keyof WorkerToHostNotifications;
+
+// ---------------------------------------------------------------------------
+// Typed Request / Response Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * A typed JSON-RPC request for a specific host→worker method.
+ */
+export type HostToWorkerRequest =
+ JsonRpcRequest;
+
+/**
+ * A typed JSON-RPC success response for a specific host→worker method.
+ */
+export type HostToWorkerResponse =
+ JsonRpcSuccessResponse;
+
+/**
+ * A typed JSON-RPC request for a specific worker→host method.
+ */
+export type WorkerToHostRequest =
+ JsonRpcRequest;
+
+/**
+ * A typed JSON-RPC success response for a specific worker→host method.
+ */
+export type WorkerToHostResponse =
+ JsonRpcSuccessResponse;
+
+// ---------------------------------------------------------------------------
+// Message Factory Functions
+// ---------------------------------------------------------------------------
+
+/** Counter for generating unique request IDs when no explicit ID is provided. */
+let _nextId = 1;
+
+/** Wrap around before reaching Number.MAX_SAFE_INTEGER to prevent precision loss. */
+const MAX_SAFE_RPC_ID = Number.MAX_SAFE_INTEGER - 1;
+
+/**
+ * Create a JSON-RPC 2.0 request message.
+ *
+ * @param method - The RPC method name
+ * @param params - Structured parameters
+ * @param id - Optional explicit request ID (auto-generated if omitted)
+ */
+export function createRequest(
+ method: TMethod,
+ params: unknown,
+ id?: JsonRpcId,
+): JsonRpcRequest {
+ if (_nextId >= MAX_SAFE_RPC_ID) {
+ _nextId = 1;
+ }
+ return {
+ jsonrpc: JSONRPC_VERSION,
+ id: id ?? _nextId++,
+ method,
+ params,
+ };
+}
+
+/**
+ * Create a JSON-RPC 2.0 success response.
+ *
+ * @param id - The request ID being responded to
+ * @param result - The result value
+ */
+export function createSuccessResponse(
+ id: JsonRpcId,
+ result: TResult,
+): JsonRpcSuccessResponse {
+ return {
+ jsonrpc: JSONRPC_VERSION,
+ id,
+ result,
+ };
+}
+
+/**
+ * Create a JSON-RPC 2.0 error response.
+ *
+ * @param id - The request ID being responded to (null if the request ID could not be determined)
+ * @param code - Machine-readable error code
+ * @param message - Human-readable error message
+ * @param data - Optional structured error data
+ */
+export function createErrorResponse(
+ id: JsonRpcId | null,
+ code: number,
+ message: string,
+ data?: TData,
+): JsonRpcErrorResponse {
+ const response: JsonRpcErrorResponse = {
+ jsonrpc: JSONRPC_VERSION,
+ id,
+ error: data !== undefined
+ ? { code, message, data }
+ : { code, message } as JsonRpcError,
+ };
+ return response;
+}
+
+/**
+ * Create a JSON-RPC 2.0 notification (fire-and-forget, no response expected).
+ *
+ * @param method - The notification method name
+ * @param params - Structured parameters
+ */
+export function createNotification(
+ method: TMethod,
+ params: unknown,
+): JsonRpcNotification {
+ return {
+ jsonrpc: JSONRPC_VERSION,
+ method,
+ params,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Type Guards
+// ---------------------------------------------------------------------------
+
+/**
+ * Check whether a value is a well-formed JSON-RPC 2.0 request.
+ *
+ * A request has `jsonrpc: "2.0"`, a string `method`, and an `id`.
+ */
+export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
+ if (typeof value !== "object" || value === null) return false;
+ const obj = value as Record;
+ return (
+ obj.jsonrpc === JSONRPC_VERSION &&
+ typeof obj.method === "string" &&
+ "id" in obj &&
+ obj.id !== undefined &&
+ obj.id !== null
+ );
+}
+
+/**
+ * Check whether a value is a well-formed JSON-RPC 2.0 notification.
+ *
+ * A notification has `jsonrpc: "2.0"`, a string `method`, but no `id`.
+ */
+export function isJsonRpcNotification(
+ value: unknown,
+): value is JsonRpcNotification {
+ if (typeof value !== "object" || value === null) return false;
+ const obj = value as Record;
+ return (
+ obj.jsonrpc === JSONRPC_VERSION &&
+ typeof obj.method === "string" &&
+ !("id" in obj)
+ );
+}
+
+/**
+ * Check whether a value is a well-formed JSON-RPC 2.0 response (success or error).
+ */
+export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse {
+ if (typeof value !== "object" || value === null) return false;
+ const obj = value as Record;
+ return (
+ obj.jsonrpc === JSONRPC_VERSION &&
+ "id" in obj &&
+ ("result" in obj || "error" in obj)
+ );
+}
+
+/**
+ * Check whether a JSON-RPC response is a success response.
+ */
+export function isJsonRpcSuccessResponse(
+ response: JsonRpcResponse,
+): response is JsonRpcSuccessResponse {
+ return "result" in response && !("error" in response && response.error !== undefined);
+}
+
+/**
+ * Check whether a JSON-RPC response is an error response.
+ */
+export function isJsonRpcErrorResponse(
+ response: JsonRpcResponse,
+): response is JsonRpcErrorResponse {
+ return "error" in response && response.error !== undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Serialization Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Line delimiter for JSON-RPC messages over stdio.
+ *
+ * Each message is a single line of JSON terminated by a newline character.
+ * This follows the newline-delimited JSON (NDJSON) convention.
+ */
+export const MESSAGE_DELIMITER = "\n" as const;
+
+/**
+ * Serialize a JSON-RPC message to a newline-delimited string for transmission
+ * over stdio.
+ *
+ * @param message - Any JSON-RPC message (request, response, or notification)
+ * @returns The JSON string terminated with a newline
+ */
+export function serializeMessage(message: JsonRpcMessage): string {
+ return JSON.stringify(message) + MESSAGE_DELIMITER;
+}
+
+/**
+ * Parse a JSON string into a JSON-RPC message.
+ *
+ * Returns the parsed message or throws a `JsonRpcParseError` if the input
+ * is not valid JSON or does not conform to the JSON-RPC 2.0 structure.
+ *
+ * @param line - A single line of JSON text (with or without trailing newline)
+ * @returns The parsed JSON-RPC message
+ * @throws {JsonRpcParseError} If parsing fails
+ */
+export function parseMessage(line: string): JsonRpcMessage {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) {
+ throw new JsonRpcParseError("Empty message");
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ throw new JsonRpcParseError(`Invalid JSON: ${trimmed.slice(0, 200)}`);
+ }
+
+ if (typeof parsed !== "object" || parsed === null) {
+ throw new JsonRpcParseError("Message must be a JSON object");
+ }
+
+ const obj = parsed as Record;
+
+ if (obj.jsonrpc !== JSONRPC_VERSION) {
+ throw new JsonRpcParseError(
+ `Invalid or missing jsonrpc version (expected "${JSONRPC_VERSION}", got ${JSON.stringify(obj.jsonrpc)})`,
+ );
+ }
+
+ // It's a valid JSON-RPC 2.0 envelope — return as-is and let the caller
+ // use the type guards for more specific classification.
+ return parsed as JsonRpcMessage;
+}
+
+// ---------------------------------------------------------------------------
+// Error Classes
+// ---------------------------------------------------------------------------
+
+/**
+ * Error thrown when a JSON-RPC message cannot be parsed.
+ */
+export class JsonRpcParseError extends Error {
+ override readonly name = "JsonRpcParseError";
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+/**
+ * Error thrown when a JSON-RPC call fails with a structured error response.
+ *
+ * Captures the full `JsonRpcError` so callers can inspect the code and data.
+ */
+export class JsonRpcCallError extends Error {
+ override readonly name = "JsonRpcCallError";
+ /** The JSON-RPC error code. */
+ readonly code: number;
+ /** Optional structured error data from the response. */
+ readonly data: unknown;
+
+ constructor(error: JsonRpcError) {
+ super(error.message);
+ this.code = error.code;
+ this.data = error.data;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Reset helper (testing only)
+// ---------------------------------------------------------------------------
+
+/**
+ * Reset the internal request ID counter. **For testing only.**
+ *
+ * @internal
+ */
+export function _resetIdCounter(): void {
+ _nextId = 1;
+}
diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts
new file mode 100644
index 00000000..ee8a38ce
--- /dev/null
+++ b/packages/plugins/sdk/src/testing.ts
@@ -0,0 +1,720 @@
+import { randomUUID } from "node:crypto";
+import type {
+ PaperclipPluginManifestV1,
+ PluginCapability,
+ PluginEventType,
+ Company,
+ Project,
+ Issue,
+ IssueComment,
+ Agent,
+ Goal,
+} from "@paperclipai/shared";
+import type {
+ EventFilter,
+ PluginContext,
+ PluginEntityRecord,
+ PluginEntityUpsert,
+ PluginJobContext,
+ PluginLauncherRegistration,
+ PluginEvent,
+ ScopeKey,
+ ToolResult,
+ ToolRunContext,
+ PluginWorkspace,
+ AgentSession,
+ AgentSessionEvent,
+} from "./types.js";
+
+export interface TestHarnessOptions {
+ /** Plugin manifest used to seed capability checks and metadata. */
+ manifest: PaperclipPluginManifestV1;
+ /** Optional capability override. Defaults to `manifest.capabilities`. */
+ capabilities?: PluginCapability[];
+ /** Initial config returned by `ctx.config.get()`. */
+ config?: Record;
+}
+
+export interface TestHarnessLogEntry {
+ level: "info" | "warn" | "error" | "debug";
+ message: string;
+ meta?: Record;
+}
+
+export interface TestHarness {
+ /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
+ ctx: PluginContext;
+ /** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
+ seed(input: {
+ companies?: Company[];
+ projects?: Project[];
+ issues?: Issue[];
+ issueComments?: IssueComment[];
+ agents?: Agent[];
+ goals?: Goal[];
+ }): void;
+ setConfig(config: Record): void;
+ /** Dispatch a host or plugin event to registered handlers. */
+ emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial): Promise;
+ /** Execute a previously-registered scheduled job handler. */
+ runJob(jobKey: string, partial?: Partial): Promise;
+ /** Invoke a `ctx.data.register(...)` handler by key. */
+ getData(key: string, params?: Record): Promise;
+ /** Invoke a `ctx.actions.register(...)` handler by key. */
+ performAction(key: string, params?: Record): Promise;
+ /** Execute a registered tool handler via `ctx.tools.execute(...)`. */
+ executeTool(name: string, params: unknown, runCtx?: Partial): Promise;
+ /** Read raw in-memory state for assertions. */
+ getState(input: ScopeKey): unknown;
+ /** Simulate a streaming event arriving for an active session. */
+ simulateSessionEvent(sessionId: string, event: Omit): void;
+ logs: TestHarnessLogEntry[];
+ activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>;
+ metrics: Array<{ name: string; value: number; tags?: Record }>;
+}
+
+type EventRegistration = {
+ name: PluginEventType | `plugin.${string}`;
+ filter?: EventFilter;
+ fn: (event: PluginEvent) => Promise;
+};
+
+function normalizeScope(input: ScopeKey): Required> & Pick {
+ return {
+ scopeKind: input.scopeKind,
+ scopeId: input.scopeId,
+ namespace: input.namespace ?? "default",
+ stateKey: input.stateKey,
+ };
+}
+
+function stateMapKey(input: ScopeKey): string {
+ const normalized = normalizeScope(input);
+ return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
+}
+
+function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
+ if (!filter) return true;
+ if (filter.companyId && filter.companyId !== String((event.payload as Record | undefined)?.companyId ?? "")) return false;
+ if (filter.projectId && filter.projectId !== String((event.payload as Record | undefined)?.projectId ?? "")) return false;
+ if (filter.agentId && filter.agentId !== String((event.payload as Record | undefined)?.agentId ?? "")) return false;
+ return true;
+}
+
+function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set, capability: PluginCapability) {
+ if (allowed.has(capability)) return;
+ throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
+}
+
+function requireCompanyId(companyId?: string): string {
+ if (!companyId) throw new Error("companyId is required for this operation");
+ return companyId;
+}
+
+function isInCompany(
+ record: T | null | undefined,
+ companyId: string,
+): record is T {
+ return Boolean(record && record.companyId === companyId);
+}
+
+/**
+ * Create an in-memory host harness for plugin worker tests.
+ *
+ * The harness enforces declared capabilities and simulates host APIs, so tests
+ * can validate plugin behavior without spinning up the Paperclip server runtime.
+ */
+export function createTestHarness(options: TestHarnessOptions): TestHarness {
+ const manifest = options.manifest;
+ const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
+ let currentConfig = { ...(options.config ?? {}) };
+
+ const logs: TestHarnessLogEntry[] = [];
+ const activity: TestHarness["activity"] = [];
+ const metrics: TestHarness["metrics"] = [];
+
+ const state = new Map();
+ const entities = new Map();
+ const entityExternalIndex = new Map();
+ const assets = new Map();
+
+ const companies = new Map();
+ const projects = new Map();
+ const issues = new Map();
+ const issueComments = new Map();
+ const agents = new Map();
+ const goals = new Map();
+ const projectWorkspaces = new Map();
+
+ const sessions = new Map();
+ const sessionEventCallbacks = new Map void>();
+
+ const events: EventRegistration[] = [];
+ const jobs = new Map Promise>();
+ const launchers = new Map();
+ const dataHandlers = new Map) => Promise>();
+ const actionHandlers = new Map) => Promise>();
+ const toolHandlers = new Map Promise>();
+
+ const ctx: PluginContext = {
+ manifest,
+ config: {
+ async get() {
+ return { ...currentConfig };
+ },
+ },
+ events: {
+ on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise): () => void {
+ requireCapability(manifest, capabilitySet, "events.subscribe");
+ let registration: EventRegistration;
+ if (typeof filterOrFn === "function") {
+ registration = { name, fn: filterOrFn };
+ } else {
+ if (!maybeFn) throw new Error("event handler is required");
+ registration = { name, filter: filterOrFn, fn: maybeFn };
+ }
+ events.push(registration);
+ return () => {
+ const idx = events.indexOf(registration);
+ if (idx !== -1) events.splice(idx, 1);
+ };
+ },
+ async emit(name, companyId, payload) {
+ requireCapability(manifest, capabilitySet, "events.emit");
+ await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
+ },
+ },
+ jobs: {
+ register(key, fn) {
+ requireCapability(manifest, capabilitySet, "jobs.schedule");
+ jobs.set(key, fn);
+ },
+ },
+ launchers: {
+ register(launcher) {
+ launchers.set(launcher.id, launcher);
+ },
+ },
+ http: {
+ async fetch(url, init) {
+ requireCapability(manifest, capabilitySet, "http.outbound");
+ return fetch(url, init);
+ },
+ },
+ secrets: {
+ async resolve(secretRef) {
+ requireCapability(manifest, capabilitySet, "secrets.read-ref");
+ 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");
+ activity.push(entry);
+ },
+ },
+ state: {
+ async get(input) {
+ requireCapability(manifest, capabilitySet, "plugin.state.read");
+ return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
+ },
+ async set(input, value) {
+ requireCapability(manifest, capabilitySet, "plugin.state.write");
+ state.set(stateMapKey(input), value);
+ },
+ async delete(input) {
+ requireCapability(manifest, capabilitySet, "plugin.state.write");
+ state.delete(stateMapKey(input));
+ },
+ },
+ entities: {
+ async upsert(input: PluginEntityUpsert) {
+ const externalKey = input.externalId
+ ? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
+ : null;
+ const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
+ const existing = existingId ? entities.get(existingId) : undefined;
+ const now = new Date().toISOString();
+ const previousExternalKey = existing?.externalId
+ ? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
+ : null;
+ const record: PluginEntityRecord = existing
+ ? {
+ ...existing,
+ entityType: input.entityType,
+ scopeKind: input.scopeKind,
+ scopeId: input.scopeId ?? null,
+ externalId: input.externalId ?? null,
+ title: input.title ?? null,
+ status: input.status ?? null,
+ data: input.data,
+ updatedAt: now,
+ }
+ : {
+ id: randomUUID(),
+ entityType: input.entityType,
+ scopeKind: input.scopeKind,
+ scopeId: input.scopeId ?? null,
+ externalId: input.externalId ?? null,
+ title: input.title ?? null,
+ status: input.status ?? null,
+ data: input.data,
+ createdAt: now,
+ updatedAt: now,
+ };
+ entities.set(record.id, record);
+ if (previousExternalKey && previousExternalKey !== externalKey) {
+ entityExternalIndex.delete(previousExternalKey);
+ }
+ if (externalKey) entityExternalIndex.set(externalKey, record.id);
+ return record;
+ },
+ async list(query) {
+ let out = [...entities.values()];
+ if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
+ if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
+ if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
+ if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
+ if (query.offset) out = out.slice(query.offset);
+ if (query.limit) out = out.slice(0, query.limit);
+ return out;
+ },
+ },
+ projects: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "projects.read");
+ const companyId = requireCompanyId(input?.companyId);
+ let out = [...projects.values()];
+ out = out.filter((project) => project.companyId === companyId);
+ if (input?.offset) out = out.slice(input.offset);
+ if (input?.limit) out = out.slice(0, input.limit);
+ return out;
+ },
+ async get(projectId, companyId) {
+ requireCapability(manifest, capabilitySet, "projects.read");
+ const project = projects.get(projectId);
+ return isInCompany(project, companyId) ? project : null;
+ },
+ async listWorkspaces(projectId, companyId) {
+ requireCapability(manifest, capabilitySet, "project.workspaces.read");
+ if (!isInCompany(projects.get(projectId), companyId)) return [];
+ return projectWorkspaces.get(projectId) ?? [];
+ },
+ async getPrimaryWorkspace(projectId, companyId) {
+ requireCapability(manifest, capabilitySet, "project.workspaces.read");
+ if (!isInCompany(projects.get(projectId), companyId)) return null;
+ const workspaces = projectWorkspaces.get(projectId) ?? [];
+ return workspaces.find((workspace) => workspace.isPrimary) ?? null;
+ },
+ async getWorkspaceForIssue(issueId, companyId) {
+ requireCapability(manifest, capabilitySet, "project.workspaces.read");
+ const issue = issues.get(issueId);
+ if (!isInCompany(issue, companyId)) return null;
+ const projectId = (issue as unknown as Record)?.projectId as string | undefined;
+ if (!projectId) return null;
+ if (!isInCompany(projects.get(projectId), companyId)) return null;
+ const workspaces = projectWorkspaces.get(projectId) ?? [];
+ return workspaces.find((workspace) => workspace.isPrimary) ?? null;
+ },
+ },
+ companies: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "companies.read");
+ let out = [...companies.values()];
+ if (input?.offset) out = out.slice(input.offset);
+ if (input?.limit) out = out.slice(0, input.limit);
+ return out;
+ },
+ async get(companyId) {
+ requireCapability(manifest, capabilitySet, "companies.read");
+ return companies.get(companyId) ?? null;
+ },
+ },
+ issues: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "issues.read");
+ const companyId = requireCompanyId(input?.companyId);
+ let out = [...issues.values()];
+ out = out.filter((issue) => issue.companyId === companyId);
+ if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
+ if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
+ if (input?.status) out = out.filter((issue) => issue.status === input.status);
+ if (input?.offset) out = out.slice(input.offset);
+ if (input?.limit) out = out.slice(0, input.limit);
+ return out;
+ },
+ async get(issueId, companyId) {
+ requireCapability(manifest, capabilitySet, "issues.read");
+ const issue = issues.get(issueId);
+ return isInCompany(issue, companyId) ? issue : null;
+ },
+ async create(input) {
+ requireCapability(manifest, capabilitySet, "issues.create");
+ const now = new Date();
+ const record: Issue = {
+ id: randomUUID(),
+ companyId: input.companyId,
+ projectId: input.projectId ?? null,
+ goalId: input.goalId ?? null,
+ parentId: input.parentId ?? null,
+ title: input.title,
+ description: input.description ?? null,
+ status: "todo",
+ priority: input.priority ?? "medium",
+ assigneeAgentId: input.assigneeAgentId ?? null,
+ assigneeUserId: null,
+ checkoutRunId: null,
+ executionRunId: null,
+ executionAgentNameKey: null,
+ executionLockedAt: null,
+ createdByAgentId: null,
+ createdByUserId: null,
+ issueNumber: null,
+ identifier: null,
+ requestDepth: 0,
+ billingCode: null,
+ assigneeAdapterOverrides: null,
+ executionWorkspaceSettings: null,
+ startedAt: null,
+ completedAt: null,
+ cancelledAt: null,
+ hiddenAt: null,
+ createdAt: now,
+ updatedAt: now,
+ };
+ issues.set(record.id, record);
+ return record;
+ },
+ async update(issueId, patch, companyId) {
+ requireCapability(manifest, capabilitySet, "issues.update");
+ const record = issues.get(issueId);
+ if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
+ const updated: Issue = {
+ ...record,
+ ...patch,
+ updatedAt: new Date(),
+ };
+ issues.set(issueId, updated);
+ return updated;
+ },
+ async listComments(issueId, companyId) {
+ requireCapability(manifest, capabilitySet, "issue.comments.read");
+ if (!isInCompany(issues.get(issueId), companyId)) return [];
+ return issueComments.get(issueId) ?? [];
+ },
+ async createComment(issueId, body, companyId) {
+ requireCapability(manifest, capabilitySet, "issue.comments.create");
+ const parentIssue = issues.get(issueId);
+ if (!isInCompany(parentIssue, companyId)) {
+ throw new Error(`Issue not found: ${issueId}`);
+ }
+ const now = new Date();
+ const comment: IssueComment = {
+ id: randomUUID(),
+ companyId: parentIssue.companyId,
+ issueId,
+ authorAgentId: null,
+ authorUserId: null,
+ body,
+ createdAt: now,
+ updatedAt: now,
+ };
+ const current = issueComments.get(issueId) ?? [];
+ current.push(comment);
+ issueComments.set(issueId, current);
+ return comment;
+ },
+ },
+ agents: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "agents.read");
+ const companyId = requireCompanyId(input?.companyId);
+ let out = [...agents.values()];
+ out = out.filter((agent) => agent.companyId === companyId);
+ if (input?.status) out = out.filter((agent) => agent.status === input.status);
+ if (input?.offset) out = out.slice(input.offset);
+ if (input?.limit) out = out.slice(0, input.limit);
+ return out;
+ },
+ async get(agentId, companyId) {
+ requireCapability(manifest, capabilitySet, "agents.read");
+ const agent = agents.get(agentId);
+ return isInCompany(agent, companyId) ? agent : null;
+ },
+ async pause(agentId, companyId) {
+ requireCapability(manifest, capabilitySet, "agents.pause");
+ const cid = requireCompanyId(companyId);
+ const agent = agents.get(agentId);
+ if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
+ if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
+ const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
+ agents.set(agentId, updated);
+ return updated;
+ },
+ async resume(agentId, companyId) {
+ requireCapability(manifest, capabilitySet, "agents.resume");
+ const cid = requireCompanyId(companyId);
+ const agent = agents.get(agentId);
+ if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
+ if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
+ if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
+ const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
+ agents.set(agentId, updated);
+ return updated;
+ },
+ async invoke(agentId, companyId, opts) {
+ requireCapability(manifest, capabilitySet, "agents.invoke");
+ const cid = requireCompanyId(companyId);
+ const agent = agents.get(agentId);
+ if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
+ if (
+ agent!.status === "paused" ||
+ agent!.status === "terminated" ||
+ agent!.status === "pending_approval"
+ ) {
+ throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
+ }
+ return { runId: randomUUID() };
+ },
+ sessions: {
+ async create(agentId, companyId, opts) {
+ requireCapability(manifest, capabilitySet, "agent.sessions.create");
+ const cid = requireCompanyId(companyId);
+ const agent = agents.get(agentId);
+ if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
+ const session: AgentSession = {
+ sessionId: randomUUID(),
+ agentId,
+ companyId: cid,
+ status: "active",
+ createdAt: new Date().toISOString(),
+ };
+ sessions.set(session.sessionId, session);
+ return session;
+ },
+ async list(agentId, companyId) {
+ requireCapability(manifest, capabilitySet, "agent.sessions.list");
+ const cid = requireCompanyId(companyId);
+ return [...sessions.values()].filter(
+ (s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
+ );
+ },
+ async sendMessage(sessionId, companyId, opts) {
+ requireCapability(manifest, capabilitySet, "agent.sessions.send");
+ const session = sessions.get(sessionId);
+ if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
+ if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
+ if (opts.onEvent) {
+ sessionEventCallbacks.set(sessionId, opts.onEvent);
+ }
+ return { runId: randomUUID() };
+ },
+ async close(sessionId, companyId) {
+ requireCapability(manifest, capabilitySet, "agent.sessions.close");
+ const session = sessions.get(sessionId);
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
+ if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
+ session.status = "closed";
+ sessionEventCallbacks.delete(sessionId);
+ },
+ },
+ },
+ goals: {
+ async list(input) {
+ requireCapability(manifest, capabilitySet, "goals.read");
+ const companyId = requireCompanyId(input?.companyId);
+ let out = [...goals.values()];
+ out = out.filter((goal) => goal.companyId === companyId);
+ if (input?.level) out = out.filter((goal) => goal.level === input.level);
+ if (input?.status) out = out.filter((goal) => goal.status === input.status);
+ if (input?.offset) out = out.slice(input.offset);
+ if (input?.limit) out = out.slice(0, input.limit);
+ return out;
+ },
+ async get(goalId, companyId) {
+ requireCapability(manifest, capabilitySet, "goals.read");
+ const goal = goals.get(goalId);
+ return isInCompany(goal, companyId) ? goal : null;
+ },
+ async create(input) {
+ requireCapability(manifest, capabilitySet, "goals.create");
+ const now = new Date();
+ const record: Goal = {
+ id: randomUUID(),
+ companyId: input.companyId,
+ title: input.title,
+ description: input.description ?? null,
+ level: input.level ?? "task",
+ status: input.status ?? "planned",
+ parentId: input.parentId ?? null,
+ ownerAgentId: input.ownerAgentId ?? null,
+ createdAt: now,
+ updatedAt: now,
+ };
+ goals.set(record.id, record);
+ return record;
+ },
+ async update(goalId, patch, companyId) {
+ requireCapability(manifest, capabilitySet, "goals.update");
+ const record = goals.get(goalId);
+ if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
+ const updated: Goal = {
+ ...record,
+ ...patch,
+ updatedAt: new Date(),
+ };
+ goals.set(goalId, updated);
+ return updated;
+ },
+ },
+ data: {
+ register(key, handler) {
+ dataHandlers.set(key, handler);
+ },
+ },
+ actions: {
+ register(key, handler) {
+ actionHandlers.set(key, handler);
+ },
+ },
+ streams: (() => {
+ const channelCompanyMap = new Map();
+ return {
+ open(channel: string, companyId: string) {
+ channelCompanyMap.set(channel, companyId);
+ },
+ emit(_channel: string, _event: unknown) {
+ // No-op in test harness — events are not forwarded
+ },
+ close(channel: string) {
+ channelCompanyMap.delete(channel);
+ },
+ };
+ })(),
+ tools: {
+ register(name, _decl, fn) {
+ requireCapability(manifest, capabilitySet, "agent.tools.register");
+ toolHandlers.set(name, fn);
+ },
+ },
+ metrics: {
+ async write(name, value, tags) {
+ requireCapability(manifest, capabilitySet, "metrics.write");
+ metrics.push({ name, value, tags });
+ },
+ },
+ logger: {
+ info(message, meta) {
+ logs.push({ level: "info", message, meta });
+ },
+ warn(message, meta) {
+ logs.push({ level: "warn", message, meta });
+ },
+ error(message, meta) {
+ logs.push({ level: "error", message, meta });
+ },
+ debug(message, meta) {
+ logs.push({ level: "debug", message, meta });
+ },
+ },
+ };
+
+ const harness: TestHarness = {
+ ctx,
+ seed(input) {
+ for (const row of input.companies ?? []) companies.set(row.id, row);
+ for (const row of input.projects ?? []) projects.set(row.id, row);
+ for (const row of input.issues ?? []) issues.set(row.id, row);
+ for (const row of input.issueComments ?? []) {
+ const list = issueComments.get(row.issueId) ?? [];
+ list.push(row);
+ issueComments.set(row.issueId, list);
+ }
+ for (const row of input.agents ?? []) agents.set(row.id, row);
+ for (const row of input.goals ?? []) goals.set(row.id, row);
+ },
+ setConfig(config) {
+ currentConfig = { ...config };
+ },
+ async emit(eventType, payload, base) {
+ const event: PluginEvent = {
+ eventId: base?.eventId ?? randomUUID(),
+ eventType,
+ companyId: base?.companyId ?? "test-company",
+ occurredAt: base?.occurredAt ?? new Date().toISOString(),
+ actorId: base?.actorId,
+ actorType: base?.actorType,
+ entityId: base?.entityId,
+ entityType: base?.entityType,
+ payload,
+ };
+
+ for (const handler of events) {
+ const exactMatch = handler.name === event.eventType;
+ const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
+ const wildcardPluginOne = String(handler.name).endsWith(".*")
+ && String(event.eventType).startsWith(String(handler.name).slice(0, -1));
+ if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
+ if (!allowsEvent(handler.filter, event)) continue;
+ await handler.fn(event);
+ }
+ },
+ async runJob(jobKey, partial = {}) {
+ const handler = jobs.get(jobKey);
+ if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
+ await handler({
+ jobKey,
+ runId: partial.runId ?? randomUUID(),
+ trigger: partial.trigger ?? "manual",
+ scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
+ });
+ },
+ async getData(key: string, params: Record = {}) {
+ const handler = dataHandlers.get(key);
+ if (!handler) throw new Error(`No data handler registered for '${key}'`);
+ return await handler(params) as T;
+ },
+ async performAction(key: string, params: Record = {}) {
+ const handler = actionHandlers.get(key);
+ if (!handler) throw new Error(`No action handler registered for '${key}'`);
+ return await handler(params) as T;
+ },
+ async executeTool(name: string, params: unknown, runCtx: Partial = {}) {
+ const handler = toolHandlers.get(name);
+ if (!handler) throw new Error(`No tool handler registered for '${name}'`);
+ const ctxToPass: ToolRunContext = {
+ agentId: runCtx.agentId ?? "agent-test",
+ runId: runCtx.runId ?? randomUUID(),
+ companyId: runCtx.companyId ?? "company-test",
+ projectId: runCtx.projectId ?? "project-test",
+ };
+ return await handler(params, ctxToPass) as T;
+ },
+ getState(input) {
+ return state.get(stateMapKey(input));
+ },
+ simulateSessionEvent(sessionId, event) {
+ const cb = sessionEventCallbacks.get(sessionId);
+ if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
+ cb({ ...event, sessionId });
+ },
+ logs,
+ activity,
+ metrics,
+ };
+
+ return harness;
+}
diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts
new file mode 100644
index 00000000..82940c7f
--- /dev/null
+++ b/packages/plugins/sdk/src/types.ts
@@ -0,0 +1,1116 @@
+/**
+ * Core types for the Paperclip plugin worker-side SDK.
+ *
+ * These types define the stable public API surface that plugin workers import
+ * from `@paperclipai/plugin-sdk`. The host provides a concrete implementation
+ * of `PluginContext` to the plugin at initialisation time.
+ *
+ * @see PLUGIN_SPEC.md §14 — SDK Surface
+ * @see PLUGIN_SPEC.md §29.2 — SDK Versioning
+ */
+
+import type {
+ PaperclipPluginManifestV1,
+ PluginStateScopeKind,
+ PluginEventType,
+ PluginToolDeclaration,
+ PluginLauncherDeclaration,
+ Company,
+ Project,
+ Issue,
+ IssueComment,
+ Agent,
+ Goal,
+} from "@paperclipai/shared";
+
+// ---------------------------------------------------------------------------
+// Re-exports from @paperclipai/shared (plugin authors import from one place)
+// ---------------------------------------------------------------------------
+
+export type {
+ PaperclipPluginManifestV1,
+ PluginJobDeclaration,
+ PluginWebhookDeclaration,
+ PluginToolDeclaration,
+ PluginUiSlotDeclaration,
+ PluginUiDeclaration,
+ PluginLauncherActionDeclaration,
+ PluginLauncherRenderDeclaration,
+ PluginLauncherDeclaration,
+ PluginMinimumHostVersion,
+ PluginRecord,
+ PluginConfig,
+ JsonSchema,
+ PluginStatus,
+ PluginCategory,
+ PluginCapability,
+ PluginUiSlotType,
+ PluginUiSlotEntityType,
+ PluginLauncherPlacementZone,
+ PluginLauncherAction,
+ PluginLauncherBounds,
+ PluginLauncherRenderEnvironment,
+ PluginStateScopeKind,
+ PluginJobStatus,
+ PluginJobRunStatus,
+ PluginJobRunTrigger,
+ PluginWebhookDeliveryStatus,
+ PluginEventType,
+ PluginBridgeErrorCode,
+ Company,
+ Project,
+ Issue,
+ IssueComment,
+ Agent,
+ Goal,
+} from "@paperclipai/shared";
+
+// ---------------------------------------------------------------------------
+// Scope key — identifies where plugin state is stored
+// ---------------------------------------------------------------------------
+
+/**
+ * A scope key identifies the exact location where plugin state is stored.
+ * Scope is partitioned by `scopeKind` and optional `scopeId`.
+ *
+ * Examples:
+ * - `{ scopeKind: "instance" }` — single global value for the whole instance
+ * - `{ scopeKind: "project", scopeId: "proj-uuid" }` — per-project state
+ * - `{ scopeKind: "issue", scopeId: "iss-uuid" }` — per-issue state
+ *
+ * @see PLUGIN_SPEC.md §21.3 `plugin_state`
+ */
+export interface ScopeKey {
+ /** What kind of Paperclip object this state is scoped to. */
+ scopeKind: PluginStateScopeKind;
+ /** UUID or text identifier for the scoped object. Omit for `instance` scope. */
+ scopeId?: string;
+ /** Optional sub-namespace within the scope to avoid key collisions. Defaults to `"default"`. */
+ namespace?: string;
+ /** The state key within the namespace. */
+ stateKey: string;
+}
+
+// ---------------------------------------------------------------------------
+// Event types
+// ---------------------------------------------------------------------------
+
+/**
+ * Optional filter applied when subscribing to an event. The host evaluates
+ * the filter server-side so filtered-out events never cross the process boundary.
+ *
+ * All filter fields are optional. If omitted the plugin receives every event
+ * of the subscribed type.
+ *
+ * @see PLUGIN_SPEC.md §16.1 — Event Filtering
+ */
+export interface EventFilter {
+ /** Only receive events for this project. */
+ projectId?: string;
+ /** Only receive events for this company. */
+ companyId?: string;
+ /** Only receive events for this agent. */
+ agentId?: string;
+ /** Additional arbitrary filter fields. */
+ [key: string]: unknown;
+}
+
+/**
+ * Envelope wrapping every domain event delivered to a plugin worker.
+ *
+ * @see PLUGIN_SPEC.md §16 — Event System
+ */
+export interface PluginEvent {
+ /** Unique event identifier (UUID). */
+ eventId: string;
+ /** The event type (e.g. `"issue.created"`). */
+ eventType: PluginEventType | `plugin.${string}`;
+ /** ISO 8601 timestamp when the event occurred. */
+ occurredAt: string;
+ /** ID of the actor that caused the event, if applicable. */
+ actorId?: string;
+ /** Type of actor: `"user"`, `"agent"`, `"system"`, or `"plugin"`. */
+ actorType?: "user" | "agent" | "system" | "plugin";
+ /** Primary entity involved in the event. */
+ entityId?: string;
+ /** Type of the primary entity. */
+ entityType?: string;
+ /** UUID of the company this event belongs to. */
+ companyId: string;
+ /** Typed event payload. */
+ payload: TPayload;
+}
+
+// ---------------------------------------------------------------------------
+// Job context
+// ---------------------------------------------------------------------------
+
+/**
+ * Context passed to a plugin job handler when the host triggers a scheduled run.
+ *
+ * @see PLUGIN_SPEC.md §13.6 — `runJob`
+ */
+export interface PluginJobContext {
+ /** Stable job key matching the declaration in the manifest. */
+ jobKey: string;
+ /** UUID for this specific job run instance. */
+ runId: string;
+ /** What triggered this run. */
+ trigger: "schedule" | "manual" | "retry";
+ /** ISO 8601 timestamp when the run was scheduled to start. */
+ scheduledAt: string;
+}
+
+// ---------------------------------------------------------------------------
+// Tool run context
+// ---------------------------------------------------------------------------
+
+/**
+ * Run context passed to a plugin tool handler when an agent invokes the tool.
+ *
+ * @see PLUGIN_SPEC.md §13.10 — `executeTool`
+ */
+export interface ToolRunContext {
+ /** UUID of the agent invoking the tool. */
+ agentId: string;
+ /** UUID of the current agent run. */
+ runId: string;
+ /** UUID of the company the run belongs to. */
+ companyId: string;
+ /** UUID of the project the run belongs to. */
+ projectId: string;
+}
+
+/**
+ * Result returned from a plugin tool handler.
+ *
+ * @see PLUGIN_SPEC.md §13.10 — `executeTool`
+ */
+export interface ToolResult {
+ /** String content returned to the agent. Required for success responses. */
+ content?: string;
+ /** Structured data returned alongside or instead of string content. */
+ data?: unknown;
+ /** If present, indicates the tool call failed. */
+ error?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin entity store
+// ---------------------------------------------------------------------------
+
+/**
+ * Input for creating or updating a plugin-owned entity.
+ *
+ * @see PLUGIN_SPEC.md §21.3 `plugin_entities`
+ */
+export interface PluginEntityUpsert {
+ /** Plugin-defined entity type (e.g. `"linear-issue"`, `"github-pr"`). */
+ entityType: string;
+ /** Scope where this entity lives. */
+ scopeKind: PluginStateScopeKind;
+ /** Optional scope ID. */
+ scopeId?: string;
+ /** External identifier in the remote system (e.g. Linear issue ID). */
+ externalId?: string;
+ /** Human-readable title for display in the Paperclip UI. */
+ title?: string;
+ /** Optional status string. */
+ status?: string;
+ /** Full entity data blob. Must be JSON-serializable. */
+ data: Record;
+}
+
+/**
+ * A plugin-owned entity record as returned by `ctx.entities.list()`.
+ *
+ * @see PLUGIN_SPEC.md §21.3 `plugin_entities`
+ */
+export interface PluginEntityRecord {
+ /** UUID primary key. */
+ id: string;
+ /** Plugin-defined entity type. */
+ entityType: string;
+ /** Scope kind. */
+ scopeKind: PluginStateScopeKind;
+ /** Scope ID, if any. */
+ scopeId: string | null;
+ /** External identifier, if any. */
+ externalId: string | null;
+ /** Human-readable title. */
+ title: string | null;
+ /** Status string. */
+ status: string | null;
+ /** Full entity data. */
+ data: Record;
+ /** ISO 8601 creation timestamp. */
+ createdAt: string;
+ /** ISO 8601 last-updated timestamp. */
+ updatedAt: string;
+}
+
+/**
+ * Query parameters for `ctx.entities.list()`.
+ */
+export interface PluginEntityQuery {
+ /** Filter by entity type. */
+ entityType?: string;
+ /** Filter by scope kind. */
+ scopeKind?: PluginStateScopeKind;
+ /** Filter by scope ID. */
+ scopeId?: string;
+ /** Filter by external ID. */
+ externalId?: string;
+ /** Maximum number of results to return. */
+ limit?: number;
+ /** Number of results to skip (for pagination). */
+ offset?: number;
+}
+
+// ---------------------------------------------------------------------------
+// Project workspace metadata (read-only via ctx.projects)
+// ---------------------------------------------------------------------------
+
+/**
+ * Workspace metadata provided by the host. Plugins use this to resolve local
+ * filesystem paths for file browsing, git, terminal, and process operations.
+ *
+ * @see PLUGIN_SPEC.md §7 — Project Workspaces
+ * @see PLUGIN_SPEC.md §20 — Local Tooling
+ */
+export interface PluginWorkspace {
+ /** UUID primary key. */
+ id: string;
+ /** UUID of the parent project. */
+ projectId: string;
+ /** Display name for this workspace. */
+ name: string;
+ /** Absolute filesystem path to the workspace directory. */
+ path: string;
+ /** Whether this is the project's primary workspace. */
+ isPrimary: boolean;
+ /** ISO 8601 creation timestamp. */
+ createdAt: string;
+ /** ISO 8601 last-updated timestamp. */
+ updatedAt: string;
+}
+
+// ---------------------------------------------------------------------------
+// Host API surfaces exposed via PluginContext
+// ---------------------------------------------------------------------------
+
+/**
+ * `ctx.config` — read resolved operator configuration for this plugin.
+ *
+ * Plugin workers receive the resolved config at initialisation. Use `get()`
+ * to access the current configuration at any time. The host calls
+ * `configChanged` on the worker when the operator updates config at runtime.
+ *
+ * @see PLUGIN_SPEC.md §13.3 — `validateConfig`
+ * @see PLUGIN_SPEC.md §13.4 — `configChanged`
+ */
+export interface PluginConfigClient {
+ /**
+ * Returns the resolved operator configuration for this plugin instance.
+ * Values are validated against the plugin's `instanceConfigSchema` by the
+ * host before being passed to the worker.
+ */
+ get(): Promise>;
+}
+
+/**
+ * `ctx.events` — subscribe to and emit Paperclip domain events.
+ *
+ * Requires `events.subscribe` capability for `on()`.
+ * Requires `events.emit` capability for `emit()`.
+ *
+ * @see PLUGIN_SPEC.md §16 — Event System
+ */
+export interface PluginEventsClient {
+ /**
+ * Subscribe to a core Paperclip domain event or a plugin-namespaced event.
+ *
+ * @param name - Event type, e.g. `"issue.created"` or `"plugin.@acme/linear.sync-done"`
+ * @param fn - Async event handler
+ */
+ on(name: PluginEventType | `plugin.${string}`, fn: (event: PluginEvent) => Promise): () => void;
+
+ /**
+ * Subscribe to an event with an optional server-side filter.
+ *
+ * @param name - Event type
+ * @param filter - Server-side filter evaluated before dispatching to the worker
+ * @param fn - Async event handler
+ * @returns An unsubscribe function that removes the handler
+ */
+ on(name: PluginEventType | `plugin.${string}`, filter: EventFilter, fn: (event: PluginEvent) => Promise): () => void;
+
+ /**
+ * Emit a plugin-namespaced event. Other plugins with `events.subscribe` can
+ * subscribe to it using `"plugin.."`.
+ *
+ * Requires the `events.emit` capability.
+ *
+ * Plugin-emitted events are automatically namespaced: if the plugin ID is
+ * `"acme.linear"` and the event name is `"sync-done"`, the full event type
+ * becomes `"plugin.acme.linear.sync-done"`.
+ *
+ * @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events
+ *
+ * @param name - Bare event name (e.g. `"sync-done"`)
+ * @param companyId - UUID of the company this event belongs to
+ * @param payload - JSON-serializable event payload
+ */
+ emit(name: string, companyId: string, payload: unknown): Promise;
+}
+
+/**
+ * `ctx.jobs` — register handlers for scheduled jobs declared in the manifest.
+ *
+ * Requires `jobs.schedule` capability.
+ *
+ * @see PLUGIN_SPEC.md §17 — Scheduled Jobs
+ */
+export interface PluginJobsClient {
+ /**
+ * Register a handler for a scheduled job.
+ *
+ * The `key` must match a `jobKey` declared in the plugin manifest.
+ * The host calls this handler according to the job's declared `schedule`.
+ *
+ * @param key - Job key matching the manifest declaration
+ * @param fn - Async job handler
+ */
+ register(key: string, fn: (job: PluginJobContext) => Promise): void;
+}
+
+/**
+ * A runtime launcher registration uses the same declaration shape as a
+ * manifest launcher entry.
+ */
+export type PluginLauncherRegistration = PluginLauncherDeclaration;
+
+/**
+ * `ctx.launchers` — register launcher declarations at runtime.
+ */
+export interface PluginLaunchersClient {
+ /**
+ * Register launcher metadata for host discovery.
+ *
+ * If a launcher with the same id is registered more than once, the latest
+ * declaration replaces the previous one.
+ */
+ register(launcher: PluginLauncherRegistration): void;
+}
+
+/**
+ * `ctx.http` — make outbound HTTP requests.
+ *
+ * Requires `http.outbound` capability.
+ *
+ * @see PLUGIN_SPEC.md §15.1 — Capabilities: Runtime/Integration
+ */
+export interface PluginHttpClient {
+ /**
+ * Perform an outbound HTTP request.
+ *
+ * The host enforces `http.outbound` capability before allowing the call.
+ * Plugins may also use standard Node `fetch` or other libraries directly —
+ * this client exists for host-managed tracing and audit logging.
+ *
+ * @param url - Target URL
+ * @param init - Standard `RequestInit` options
+ * @returns The response
+ */
+ fetch(url: string, init?: RequestInit): Promise;
+}
+
+/**
+ * `ctx.secrets` — resolve secret references.
+ *
+ * Requires `secrets.read-ref` capability.
+ *
+ * Plugins store secret *references* in their config (e.g. a secret name).
+ * This client resolves the reference through the Paperclip secret provider
+ * system and returns the resolved value at execution time.
+ *
+ * @see PLUGIN_SPEC.md §22 — Secrets
+ */
+export interface PluginSecretsClient {
+ /**
+ * Resolve a secret reference to its current value.
+ *
+ * The reference is a string identifier pointing to a secret configured
+ * in the Paperclip secret provider (e.g. `"MY_API_KEY"`).
+ *
+ * Secret values are resolved at call time and must never be cached or
+ * written to logs, config, or other persistent storage.
+ *
+ * @param secretRef - The secret reference string from plugin config
+ * @returns The resolved secret value
+ */
+ resolve(secretRef: string): Promise;
+}
+
+/**
+ * `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;
+}
+
+/**
+ * Input for writing a plugin activity log entry.
+ *
+ * @see PLUGIN_SPEC.md §21.4 — Activity Log Changes
+ */
+export interface PluginActivityLogEntry {
+ /** UUID of the company this activity belongs to. Required for auditing. */
+ companyId: string;
+ /** Human-readable description of the activity. */
+ message: string;
+ /** Optional entity type this activity relates to. */
+ entityType?: string;
+ /** Optional entity ID this activity relates to. */
+ entityId?: string;
+ /** Optional additional metadata. */
+ metadata?: Record;
+}
+
+/**
+ * `ctx.activity` — write plugin-originated activity log entries.
+ *
+ * Requires `activity.log.write` capability.
+ *
+ * @see PLUGIN_SPEC.md §21.4 — Activity Log Changes
+ */
+export interface PluginActivityClient {
+ /**
+ * Write an activity log entry attributed to this plugin.
+ *
+ * The host writes the entry with `actor_type = plugin` and
+ * `actor_id = `.
+ *
+ * @param entry - The activity log entry to write
+ */
+ log(entry: PluginActivityLogEntry): Promise;
+}
+
+/**
+ * `ctx.state` — read and write plugin-scoped key-value state.
+ *
+ * Each plugin gets an isolated namespace: state written by plugin A can never
+ * be read or overwritten by plugin B. Within a plugin, state is partitioned by
+ * a five-part composite key: `(pluginId, scopeKind, scopeId, namespace, stateKey)`.
+ *
+ * **Scope kinds**
+ *
+ * | `scopeKind` | `scopeId` | Typical use |
+ * |-------------|-----------|-------------|
+ * | `"instance"` | omit | Global flags, last full-sync timestamps |
+ * | `"company"` | company UUID | Per-company sync cursors |
+ * | `"project"` | project UUID | Per-project settings, branch tracking |
+ * | `"project_workspace"` | workspace UUID | Per-workspace state |
+ * | `"agent"` | agent UUID | Per-agent memory |
+ * | `"issue"` | issue UUID | Idempotency keys, linked external IDs |
+ * | `"goal"` | goal UUID | Per-goal progress |
+ * | `"run"` | run UUID | Per-run checkpoints |
+ *
+ * **Namespaces**
+ *
+ * The optional `namespace` field (default: `"default"`) lets you group related
+ * keys within a scope without risking collisions between different logical
+ * subsystems inside the same plugin.
+ *
+ * **Security**
+ *
+ * Never store resolved secret values. Store only secret references and resolve
+ * them at call time via `ctx.secrets.resolve()`.
+ *
+ * @example
+ * ```ts
+ * // Instance-global flag
+ * await ctx.state.set({ scopeKind: "instance", stateKey: "schema-version" }, 2);
+ *
+ * // Idempotency key per issue
+ * const synced = await ctx.state.get({ scopeKind: "issue", scopeId: issueId, stateKey: "synced-to-linear" });
+ * if (!synced) {
+ * await syncToLinear(issueId);
+ * await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "synced-to-linear" }, true);
+ * }
+ *
+ * // Per-project, namespaced for two integrations
+ * await ctx.state.set({ scopeKind: "project", scopeId: projectId, namespace: "linear", stateKey: "cursor" }, cursor);
+ * await ctx.state.set({ scopeKind: "project", scopeId: projectId, namespace: "github", stateKey: "last-event" }, eventId);
+ * ```
+ *
+ * `plugin.state.read` capability required for `get()`.
+ * `plugin.state.write` capability required for `set()` and `delete()`.
+ *
+ * @see PLUGIN_SPEC.md §21.3 `plugin_state`
+ */
+export interface PluginStateClient {
+ /**
+ * Read a state value.
+ *
+ * Returns the stored JSON value as-is, or `null` if no entry has been set
+ * for this scope+key combination. Falsy values (`false`, `0`, `""`) are
+ * returned correctly and are not confused with "not set".
+ *
+ * @param input - Scope key identifying the entry to read
+ * @returns The stored JSON value, or `null` if no value has been set
+ */
+ get(input: ScopeKey): Promise;
+
+ /**
+ * Write a state value. Creates the row if it does not exist; replaces it
+ * atomically (upsert) if it does. Safe to call concurrently.
+ *
+ * Any JSON-serializable value is accepted: objects, arrays, strings,
+ * numbers, booleans, and `null`.
+ *
+ * @param input - Scope key identifying the entry to write
+ * @param value - JSON-serializable value to store
+ */
+ set(input: ScopeKey, value: unknown): Promise;
+
+ /**
+ * Delete a state value. No-ops silently if the entry does not exist
+ * (idempotent by design — safe to call without prior `get()`).
+ *
+ * @param input - Scope key identifying the entry to delete
+ */
+ delete(input: ScopeKey): Promise;
+}
+
+/**
+ * `ctx.entities` — create and query plugin-owned entity records.
+ *
+ * @see PLUGIN_SPEC.md §21.3 `plugin_entities`
+ */
+export interface PluginEntitiesClient {
+ /**
+ * Create or update a plugin entity record (upsert by `externalId` within
+ * the given scope, or by `id` if provided).
+ *
+ * @param input - Entity data to upsert
+ */
+ upsert(input: PluginEntityUpsert): Promise;
+
+ /**
+ * Query plugin entity records.
+ *
+ * @param query - Filter criteria
+ * @returns Matching entity records
+ */
+ list(query: PluginEntityQuery): Promise;
+}
+
+/**
+ * `ctx.projects` — read project and workspace metadata.
+ *
+ * Requires `projects.read` capability.
+ * Requires `project.workspaces.read` capability for workspace operations.
+ *
+ * @see PLUGIN_SPEC.md §7 — Project Workspaces
+ */
+export interface PluginProjectsClient {
+ /**
+ * List projects visible to the plugin.
+ *
+ * Requires the `projects.read` capability.
+ */
+ list(input: { companyId: string; limit?: number; offset?: number }): Promise;
+
+ /**
+ * Get a single project by ID.
+ *
+ * Requires the `projects.read` capability.
+ */
+ get(projectId: string, companyId: string): Promise;
+
+ /**
+ * List all workspaces attached to a project.
+ *
+ * @param projectId - UUID of the project
+ * @param companyId - UUID of the company that owns the project
+ * @returns All workspaces for the project, ordered with primary first
+ */
+ listWorkspaces(projectId: string, companyId: string): Promise;
+
+ /**
+ * Get the primary workspace for a project.
+ *
+ * @param projectId - UUID of the project
+ * @param companyId - UUID of the company that owns the project
+ * @returns The primary workspace, or `null` if no workspace is configured
+ */
+ getPrimaryWorkspace(projectId: string, companyId: string): Promise;
+
+ /**
+ * Resolve the primary workspace for an issue by looking up the issue's
+ * project and returning its primary workspace.
+ *
+ * This is a convenience method that combines `issues.get()` and
+ * `getPrimaryWorkspace()` in a single RPC call.
+ *
+ * @param issueId - UUID of the issue
+ * @param companyId - UUID of the company that owns the issue
+ * @returns The primary workspace for the issue's project, or `null` if
+ * the issue has no project or the project has no workspace
+ *
+ * @see PLUGIN_SPEC.md §20 — Local Tooling
+ */
+ getWorkspaceForIssue(issueId: string, companyId: string): Promise;
+}
+
+/**
+ * `ctx.data` — register `getData` handlers that back `usePluginData()` in the
+ * plugin's frontend components.
+ *
+ * The plugin's UI calls `usePluginData(key, params)` which routes through the
+ * host bridge to the worker's registered handler.
+ *
+ * @see PLUGIN_SPEC.md §13.8 — `getData`
+ */
+export interface PluginDataClient {
+ /**
+ * Register a handler for a plugin-defined data key.
+ *
+ * @param key - Stable string identifier for this data type (e.g. `"sync-health"`)
+ * @param handler - Async function that receives request params and returns JSON-serializable data
+ */
+ register(key: string, handler: (params: Record) => Promise): void;
+}
+
+/**
+ * `ctx.actions` — register `performAction` handlers that back
+ * `usePluginAction()` in the plugin's frontend components.
+ *
+ * @see PLUGIN_SPEC.md §13.9 — `performAction`
+ */
+export interface PluginActionsClient {
+ /**
+ * Register a handler for a plugin-defined action key.
+ *
+ * @param key - Stable string identifier for this action (e.g. `"resync"`)
+ * @param handler - Async function that receives action params and returns a result
+ */
+ register(key: string, handler: (params: Record) => Promise): void;
+}
+
+/**
+ * `ctx.tools` — register handlers for agent tools declared in the manifest.
+ *
+ * Requires `agent.tools.register` capability.
+ *
+ * Tool names are automatically namespaced by plugin ID at runtime.
+ *
+ * @see PLUGIN_SPEC.md §11 — Agent Tools
+ */
+export interface PluginToolsClient {
+ /**
+ * Register a handler for a plugin-contributed agent tool.
+ *
+ * @param name - Tool name matching the manifest declaration (without namespace prefix)
+ * @param declaration - Tool metadata (displayName, description, parametersSchema)
+ * @param fn - Async handler that executes the tool
+ */
+ register(
+ name: string,
+ declaration: Pick,
+ fn: (params: unknown, runCtx: ToolRunContext) => Promise,
+ ): void;
+}
+
+/**
+ * `ctx.logger` — structured logging from the plugin worker.
+ *
+ * Log output is captured by the host, stored, and surfaced in the plugin
+ * health dashboard.
+ *
+ * @see PLUGIN_SPEC.md §26.1 — Logging
+ */
+export interface PluginLogger {
+ /** Log an informational message. */
+ info(message: string, meta?: Record): void;
+ /** Log a warning. */
+ warn(message: string, meta?: Record): void;
+ /** Log an error. */
+ error(message: string, meta?: Record): void;
+ /** Log a debug message (may be suppressed in production). */
+ debug(message: string, meta?: Record): void;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin metrics
+// ---------------------------------------------------------------------------
+
+/**
+ * `ctx.metrics` — write plugin-contributed metrics.
+ *
+ * Requires `metrics.write` capability.
+ *
+ * @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write
+ */
+export interface PluginMetricsClient {
+ /**
+ * Write a numeric metric data point.
+ *
+ * @param name - Metric name (plugin-namespaced by the host)
+ * @param value - Numeric value
+ * @param tags - Optional key-value tags for filtering
+ */
+ write(name: string, value: number, tags?: Record): Promise;
+}
+
+/**
+ * `ctx.companies` — read company metadata.
+ *
+ * Requires `companies.read` capability.
+ */
+export interface PluginCompaniesClient {
+ /**
+ * List companies visible to this plugin.
+ */
+ list(input?: { limit?: number; offset?: number }): Promise;
+
+ /**
+ * Get one company by ID.
+ */
+ get(companyId: string): Promise;
+}
+
+/**
+ * `ctx.issues` — read and mutate issues plus comments.
+ *
+ * Requires:
+ * - `issues.read` for read operations
+ * - `issues.create` for create
+ * - `issues.update` for update
+ * - `issue.comments.read` for `listComments`
+ * - `issue.comments.create` for `createComment`
+ */
+export interface PluginIssuesClient {
+ list(input: {
+ companyId: string;
+ projectId?: string;
+ assigneeAgentId?: string;
+ status?: Issue["status"];
+ limit?: number;
+ offset?: number;
+ }): Promise;
+ get(issueId: string, companyId: string): Promise;
+ create(input: {
+ companyId: string;
+ projectId?: string;
+ goalId?: string;
+ parentId?: string;
+ title: string;
+ description?: string;
+ priority?: Issue["priority"];
+ assigneeAgentId?: string;
+ }): Promise;
+ update(
+ issueId: string,
+ patch: Partial>,
+ companyId: string,
+ ): Promise;
+ listComments(issueId: string, companyId: string): Promise;
+ createComment(issueId: string, body: string, companyId: string): Promise;
+}
+
+/**
+ * `ctx.agents` — read and manage agents.
+ *
+ * Requires `agents.read` for reads; `agents.pause` / `agents.resume` /
+ * `agents.invoke` for write operations.
+ */
+export interface PluginAgentsClient {
+ list(input: { companyId: string; status?: Agent["status"]; limit?: number; offset?: number }): Promise;
+ get(agentId: string, companyId: string): Promise;
+ /** Pause an agent. Throws if agent is terminated or not found. Requires `agents.pause`. */
+ pause(agentId: string, companyId: string): Promise;
+ /** Resume a paused agent (sets status to idle). Throws if terminated, pending_approval, or not found. Requires `agents.resume`. */
+ resume(agentId: string, companyId: string): Promise;
+ /** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */
+ invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>;
+ /** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */
+ sessions: PluginAgentSessionsClient;
+}
+
+// ---------------------------------------------------------------------------
+// Agent Sessions — two-way chat with agents
+// ---------------------------------------------------------------------------
+
+/**
+ * Represents an active conversational session with an agent.
+ * Maps to an `AgentTaskSession` row on the host.
+ */
+export interface AgentSession {
+ sessionId: string;
+ agentId: string;
+ companyId: string;
+ status: "active" | "closed";
+ createdAt: string;
+}
+
+/**
+ * A streaming event received during a session's `sendMessage` call.
+ * Delivered via JSON-RPC notifications from host to worker.
+ */
+export interface AgentSessionEvent {
+ sessionId: string;
+ runId: string;
+ seq: number;
+ /** The kind of event: "chunk" for output data, "status" for run state changes, "done" for end-of-stream, "error" for failures. */
+ eventType: "chunk" | "status" | "done" | "error";
+ stream: "stdout" | "stderr" | "system" | null;
+ message: string | null;
+ payload: Record | null;
+}
+
+/**
+ * Result of sending a message to a session.
+ */
+export interface AgentSessionSendResult {
+ runId: string;
+}
+
+/**
+ * `ctx.agents.sessions` — create, message, and close agent chat sessions.
+ *
+ * Requires `agent.sessions.create` for create, `agent.sessions.list` for list,
+ * `agent.sessions.send` for sendMessage, `agent.sessions.close` for close.
+ */
+export interface PluginAgentSessionsClient {
+ /** Create a new conversational session with an agent. Requires `agent.sessions.create`. */
+ create(agentId: string, companyId: string, opts?: {
+ taskKey?: string;
+ reason?: string;
+ }): Promise;
+
+ /** List active sessions for an agent owned by this plugin. Requires `agent.sessions.list`. */
+ list(agentId: string, companyId: string): Promise;
+
+ /**
+ * Send a message to a session and receive streaming events via the `onEvent` callback.
+ * Returns immediately with `{ runId }`. Events are delivered asynchronously.
+ * Requires `agent.sessions.send`.
+ */
+ sendMessage(sessionId: string, companyId: string, opts: {
+ prompt: string;
+ reason?: string;
+ onEvent?: (event: AgentSessionEvent) => void;
+ }): Promise;
+
+ /** Close a session, releasing resources. Requires `agent.sessions.close`. */
+ close(sessionId: string, companyId: string): Promise;
+}
+
+/**
+ * `ctx.goals` — read and mutate goals.
+ *
+ * Requires:
+ * - `goals.read` for read operations
+ * - `goals.create` for create
+ * - `goals.update` for update
+ */
+export interface PluginGoalsClient {
+ list(input: {
+ companyId: string;
+ level?: Goal["level"];
+ status?: Goal["status"];
+ limit?: number;
+ offset?: number;
+ }): Promise;
+ get(goalId: string, companyId: string): Promise;
+ create(input: {
+ companyId: string;
+ title: string;
+ description?: string;
+ level?: Goal["level"];
+ status?: Goal["status"];
+ parentId?: string;
+ ownerAgentId?: string;
+ }): Promise;
+ update(
+ goalId: string,
+ patch: Partial>,
+ companyId: string,
+ ): Promise;
+}
+
+// ---------------------------------------------------------------------------
+// Streaming (worker → UI push channel)
+// ---------------------------------------------------------------------------
+
+/**
+ * `ctx.streams` — push real-time events from the worker to the plugin UI.
+ *
+ * The worker opens a named channel, emits events on it, and closes it when
+ * done. On the UI side, `usePluginStream(channel)` receives these events in
+ * real time via SSE.
+ *
+ * Streams are scoped to `(pluginId, channel, companyId)`. Multiple UI clients
+ * can subscribe to the same channel concurrently.
+ *
+ * @example
+ * ```ts
+ * // Worker: stream chat tokens to the UI
+ * ctx.streams.open("chat", companyId);
+ * for await (const token of tokenStream) {
+ * ctx.streams.emit("chat", { type: "token", text: token });
+ * }
+ * ctx.streams.close("chat");
+ * ```
+ *
+ * @see usePluginStream in `@paperclipai/plugin-sdk/ui`
+ */
+export interface PluginStreamsClient {
+ /**
+ * Open a named stream channel. Optional — `emit()` implicitly opens if needed.
+ * Sends a `stream:open` event to connected UI clients.
+ */
+ open(channel: string, companyId: string): void;
+
+ /**
+ * Push an event to all UI clients subscribed to this channel.
+ *
+ * @param channel - Stream channel name (e.g. `"chat"`, `"logs"`)
+ * @param event - JSON-serializable event payload
+ */
+ emit(channel: string, event: unknown): void;
+
+ /**
+ * Close a stream channel. Sends a `stream:close` event to connected UI
+ * clients so they know no more events will arrive.
+ */
+ close(channel: string): void;
+}
+
+// ---------------------------------------------------------------------------
+// Full plugin context
+// ---------------------------------------------------------------------------
+
+/**
+ * The full plugin context object passed to the plugin worker at initialisation.
+ *
+ * This is the central interface plugin authors use to interact with the host.
+ * Every client is capability-gated: calling a client method without the
+ * required capability declared in the manifest results in a runtime error.
+ *
+ * @example
+ * ```ts
+ * import { definePlugin } from "@paperclipai/plugin-sdk";
+ *
+ * export default definePlugin({
+ * async setup(ctx) {
+ * ctx.events.on("issue.created", async (event) => {
+ * ctx.logger.info("Issue created", { issueId: event.entityId });
+ * });
+ *
+ * ctx.data.register("sync-health", async ({ companyId }) => {
+ * const state = await ctx.state.get({ scopeKind: "company", scopeId: String(companyId), stateKey: "last-sync" });
+ * return { lastSync: state };
+ * });
+ * },
+ * });
+ * ```
+ *
+ * @see PLUGIN_SPEC.md §14 — SDK Surface
+ */
+export interface PluginContext {
+ /** The plugin's manifest as validated at install time. */
+ manifest: PaperclipPluginManifestV1;
+
+ /** Read resolved operator configuration. */
+ config: PluginConfigClient;
+
+ /** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */
+ events: PluginEventsClient;
+
+ /** Register handlers for scheduled jobs. Requires `jobs.schedule`. */
+ jobs: PluginJobsClient;
+
+ /** Register launcher metadata that the host can surface in plugin UI entry points. */
+ launchers: PluginLaunchersClient;
+
+ /** Make outbound HTTP requests. Requires `http.outbound`. */
+ http: PluginHttpClient;
+
+ /** 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;
+
+ /** Read and write scoped plugin state. Requires `plugin.state.read` / `plugin.state.write`. */
+ state: PluginStateClient;
+
+ /** Create and query plugin-owned entity records. */
+ entities: PluginEntitiesClient;
+
+ /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
+ projects: PluginProjectsClient;
+
+ /** Read company metadata. Requires `companies.read`. */
+ companies: PluginCompaniesClient;
+
+ /** Read and write issues/comments. Requires issue capabilities. */
+ issues: PluginIssuesClient;
+
+ /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */
+ agents: PluginAgentsClient;
+
+ /** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */
+ goals: PluginGoalsClient;
+
+ /** Register getData handlers for the plugin's UI components. */
+ data: PluginDataClient;
+
+ /** Register performAction handlers for the plugin's UI components. */
+ actions: PluginActionsClient;
+
+ /** Push real-time events from the worker to the plugin UI via SSE. */
+ streams: PluginStreamsClient;
+
+ /** Register agent tool handlers. Requires `agent.tools.register`. */
+ tools: PluginToolsClient;
+
+ /** Write plugin metrics. Requires `metrics.write`. */
+ metrics: PluginMetricsClient;
+
+ /** Structured logger. Output is captured and surfaced in the plugin health dashboard. */
+ logger: PluginLogger;
+}
diff --git a/packages/plugins/sdk/src/ui/components.ts b/packages/plugins/sdk/src/ui/components.ts
new file mode 100644
index 00000000..b93c1db4
--- /dev/null
+++ b/packages/plugins/sdk/src/ui/components.ts
@@ -0,0 +1,310 @@
+/**
+ * Shared UI component declarations for plugin frontends.
+ *
+ * These components are exported from `@paperclipai/plugin-sdk/ui` and are
+ * provided by the host at runtime. They match the host's design tokens and
+ * visual language, reducing the boilerplate needed to build consistent plugin UIs.
+ *
+ * **Plugins are not required to use these components.** They exist to reduce
+ * boilerplate and keep visual consistency. A plugin may render entirely custom
+ * UI using any React component library.
+ *
+ * Component implementations are provided by the host — plugin bundles contain
+ * only the type declarations; the runtime implementations are injected via the
+ * host module registry.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui`
+ */
+
+import type React from "react";
+import { renderSdkUiComponent } from "./runtime.js";
+
+// ---------------------------------------------------------------------------
+// Component prop interfaces
+// ---------------------------------------------------------------------------
+
+/**
+ * A trend value that can accompany a metric.
+ * Positive values indicate upward trends; negative values indicate downward trends.
+ */
+export interface MetricTrend {
+ /** Direction of the trend. */
+ direction: "up" | "down" | "flat";
+ /** Percentage change value (e.g. `12.5` for 12.5%). */
+ percentage?: number;
+}
+
+/** Props for `MetricCard`. */
+export interface MetricCardProps {
+ /** Short label describing the metric (e.g. `"Synced Issues"`). */
+ label: string;
+ /** The metric value to display. */
+ value: number | string;
+ /** Optional trend indicator. */
+ trend?: MetricTrend;
+ /** Optional sparkline data (array of numbers, latest last). */
+ sparkline?: number[];
+ /** Optional unit suffix (e.g. `"%"`, `"ms"`). */
+ unit?: string;
+}
+
+/** Status variants for `StatusBadge`. */
+export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
+
+/** Props for `StatusBadge`. */
+export interface StatusBadgeProps {
+ /** Human-readable label. */
+ label: string;
+ /** Visual variant determining colour. */
+ status: StatusBadgeVariant;
+}
+
+/** A single column definition for `DataTable`. */
+export interface DataTableColumn> {
+ /** Column key, matching a field on the row object. */
+ key: keyof T & string;
+ /** Column header label. */
+ header: string;
+ /** Optional custom cell renderer. */
+ render?: (value: unknown, row: T) => React.ReactNode;
+ /** Whether this column is sortable. */
+ sortable?: boolean;
+ /** CSS width (e.g. `"120px"`, `"20%"`). */
+ width?: string;
+}
+
+/** Props for `DataTable`. */
+export interface DataTableProps> {
+ /** Column definitions. */
+ columns: DataTableColumn[];
+ /** Row data. Each row should have a stable `id` field. */
+ rows: T[];
+ /** Whether the table is currently loading. */
+ loading?: boolean;
+ /** Message shown when `rows` is empty. */
+ emptyMessage?: string;
+ /** Total row count for pagination (if different from `rows.length`). */
+ totalCount?: number;
+ /** Current page (0-based, for pagination). */
+ page?: number;
+ /** Rows per page (for pagination). */
+ pageSize?: number;
+ /** Callback when page changes. */
+ onPageChange?: (page: number) => void;
+ /** Callback when a column header is clicked to sort. */
+ onSort?: (key: string, direction: "asc" | "desc") => void;
+}
+
+/** A single data point for `TimeseriesChart`. */
+export interface TimeseriesDataPoint {
+ /** ISO 8601 timestamp. */
+ timestamp: string;
+ /** Numeric value. */
+ value: number;
+ /** Optional label for the point. */
+ label?: string;
+}
+
+/** Props for `TimeseriesChart`. */
+export interface TimeseriesChartProps {
+ /** Series data. */
+ data: TimeseriesDataPoint[];
+ /** Chart title. */
+ title?: string;
+ /** Y-axis label. */
+ yLabel?: string;
+ /** Chart type. Defaults to `"line"`. */
+ type?: "line" | "bar";
+ /** Height of the chart in pixels. Defaults to `200`. */
+ height?: number;
+ /** Whether the chart is currently loading. */
+ loading?: boolean;
+}
+
+/** Props for `MarkdownBlock`. */
+export interface MarkdownBlockProps {
+ /** Markdown content to render. */
+ content: string;
+}
+
+/** A single key-value pair for `KeyValueList`. */
+export interface KeyValuePair {
+ /** Label for the key. */
+ label: string;
+ /** Value to display. May be a string, number, or a React node. */
+ value: React.ReactNode;
+}
+
+/** Props for `KeyValueList`. */
+export interface KeyValueListProps {
+ /** Pairs to render in the list. */
+ pairs: KeyValuePair[];
+}
+
+/** A single action button for `ActionBar`. */
+export interface ActionBarItem {
+ /** Button label. */
+ label: string;
+ /** Action key to call via the plugin bridge. */
+ actionKey: string;
+ /** Optional parameters to pass to the action handler. */
+ params?: Record;
+ /** Button variant. Defaults to `"default"`. */
+ variant?: "default" | "primary" | "destructive";
+ /** Whether to show a confirmation dialog before executing. */
+ confirm?: boolean;
+ /** Text for the confirmation dialog (used when `confirm` is true). */
+ confirmMessage?: string;
+}
+
+/** Props for `ActionBar`. */
+export interface ActionBarProps {
+ /** Action definitions. */
+ actions: ActionBarItem[];
+ /** Called after an action succeeds. Use to trigger data refresh. */
+ onSuccess?: (actionKey: string, result: unknown) => void;
+ /** Called when an action fails. */
+ onError?: (actionKey: string, error: unknown) => void;
+}
+
+/** A single log line for `LogView`. */
+export interface LogViewEntry {
+ /** ISO 8601 timestamp. */
+ timestamp: string;
+ /** Log level. */
+ level: "info" | "warn" | "error" | "debug";
+ /** Log message. */
+ message: string;
+ /** Optional structured metadata. */
+ meta?: Record;
+}
+
+/** Props for `LogView`. */
+export interface LogViewProps {
+ /** Log entries to display. */
+ entries: LogViewEntry[];
+ /** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */
+ maxHeight?: string;
+ /** Whether to auto-scroll to the latest entry. */
+ autoScroll?: boolean;
+ /** Whether the log is currently loading. */
+ loading?: boolean;
+}
+
+/** Props for `JsonTree`. */
+export interface JsonTreeProps {
+ /** The data to render as a collapsible JSON tree. */
+ data: unknown;
+ /** Initial depth to expand. Defaults to `2`. */
+ defaultExpandDepth?: number;
+}
+
+/** Props for `Spinner`. */
+export interface SpinnerProps {
+ /** Size of the spinner. Defaults to `"md"`. */
+ size?: "sm" | "md" | "lg";
+ /** Accessible label for the spinner (used as `aria-label`). */
+ label?: string;
+}
+
+/** Props for `ErrorBoundary`. */
+export interface ErrorBoundaryProps {
+ /** Content to render inside the error boundary. */
+ children: React.ReactNode;
+ /** Optional custom fallback to render when an error is caught. */
+ fallback?: React.ReactNode;
+ /** Called when an error is caught, for logging or reporting. */
+ onError?: (error: Error, info: React.ErrorInfo) => void;
+}
+
+// ---------------------------------------------------------------------------
+// Component declarations (provided by host at runtime)
+// ---------------------------------------------------------------------------
+
+// These are declared as ambient values so plugin TypeScript code can import
+// and use them with full type-checking. The host's module registry provides
+// the concrete React component implementations at bundle load time.
+
+/**
+ * Displays a single metric with an optional trend indicator and sparkline.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+function createSdkUiComponent(name: string): React.ComponentType {
+ return function PaperclipSdkUiComponent(props: TProps) {
+ return renderSdkUiComponent(name, props) as React.ReactNode;
+ };
+}
+
+export const MetricCard = createSdkUiComponent("MetricCard");
+
+/**
+ * Displays an inline status badge (ok / warning / error / info / pending).
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+export const StatusBadge = createSdkUiComponent("StatusBadge");
+
+/**
+ * Sortable, paginated data table.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+export const DataTable = createSdkUiComponent("DataTable");
+
+/**
+ * Line or bar chart for time-series data.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+export const TimeseriesChart = createSdkUiComponent("TimeseriesChart");
+
+/**
+ * Renders Markdown text as HTML.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+export const MarkdownBlock = createSdkUiComponent("MarkdownBlock");
+
+/**
+ * Renders a definition-list of label/value pairs.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+export const KeyValueList = createSdkUiComponent("KeyValueList");
+
+/**
+ * Row of action buttons wired to the plugin bridge's `performAction` handlers.
+ *
+ * @see PLUGIN_SPEC.md §19.6 — Shared Components
+ */
+export const ActionBar = createSdkUiComponent