doc/plugins/PLUGIN_SPEC.md
Status: proposed complete spec for the post-V1 plugin system
This document is the complete specification for Paperclip's plugin and extension architecture. It expands the brief plugin notes in doc/SPEC.md and should be read alongside the comparative analysis in doc/plugins/ideas-from-opencode.md.
This is not part of the V1 implementation contract in doc/SPEC-implementation.md. It is the full target architecture for the plugin system that should follow V1.
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:
Current limitations to keep in mind:
npm is available in the running environment and that the host can reach the configured package registry.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.apiRoutes.
They mount under /api/plugins/:pluginId/api/*; plugins cannot shadow core
API routes.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.
This spec covers:
This spec does not cover:
Paperclip plugin design is based on the following assumptions:
project_workspaces, and local/runtime plugins should build on that instead of inventing a separate workspace abstraction.The plugin system must:
The first plugin system must not:
.paperclip/plugins.The single Paperclip deployment an operator installs and controls.
A first-class Paperclip business object inside the instance.
A workspace attached to a project through project_workspaces.
Plugins resolve workspace paths from this model to locate local directories for file, terminal, git, and process operations.
A trusted in-process extension loaded directly by Paperclip core.
Examples:
An installable instance-wide extension package loaded through the Paperclip plugin runtime.
Examples:
The runtime process used for a plugin. In this spec, third-party plugins run out-of-process by default.
A named permission the host grants to a plugin. Plugins may only call host APIs that are covered by granted capabilities.
Paperclip has two extension classes.
Platform modules are:
They use explicit registries, not the general plugin worker protocol.
Platform module surfaces:
registerAgentAdapter()registerStorageProvider()registerSecretProvider()registerRunLogStore()Platform modules are the right place for:
Plugins are:
Plugin categories:
connectorworkspaceautomationuiA plugin may declare more than one category.
Paperclip already has a concrete workspace model:
workspacesprimaryWorkspaceproject_workspacesPlugins that need local tooling (file browsing, git, terminals, process tracking) can resolve workspace paths through the project workspace APIs and then operate on the filesystem, spawn processes, and run git commands directly. The host does not wrap these operations — plugins own their own implementations.
Plugin installation is global and operator-driven.
There is no per-company install table and no per-company enable/disable switch.
If a plugin needs business-object-specific mappings, those are stored as plugin configuration or plugin state.
Examples:
project_workspacePlugins live under the Paperclip instance directory.
Suggested layout:
~/.paperclip/instances/default/plugins/package.json~/.paperclip/instances/default/plugins/node_modules/~/.paperclip/instances/default/plugins/.cache/~/.paperclip/instances/default/data/plugins/<plugin-id>/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.
Paperclip should add CLI commands:
pnpm paperclipai plugin listpnpm paperclipai plugin install <package[@version]>pnpm paperclipai plugin uninstall <plugin-id>pnpm paperclipai plugin upgrade <plugin-id> [version]pnpm paperclipai plugin doctor <plugin-id>These commands are instance-level operations.
The install process is:
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.
Load order must be deterministic.
idRules:
@paperclip/plugin-linear:sync-health-widget), so cross-plugin collisions are structurally impossibleEach plugin package must export a manifest, a worker entrypoint, and optionally a UI bundle.
Suggested package layout:
dist/manifest.jsdist/worker.jsdist/ui/ (optional, contains the plugin's frontend bundle)Suggested package.json keys:
{
"name": "@paperclip/plugin-linear",
"version": "0.1.0",
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
}
}
Normative manifest shape:
export interface PaperclipPluginManifestV1 {
id: string;
apiVersion: 1;
version: string;
displayName: string;
description: string;
categories: Array<"connector" | "workspace" | "automation" | "ui">;
minimumPaperclipVersion?: string;
capabilities: string[];
entrypoints: {
worker: string;
ui?: string;
};
instanceConfigSchema?: JsonSchema;
jobs?: PluginJobDeclaration[];
webhooks?: PluginWebhookDeclaration[];
tools?: Array<{
name: string;
displayName: string;
description: string;
parametersSchema: JsonSchema;
}>;
ui?: {
slots: Array<{
type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage";
id: string;
displayName: string;
/** Which export name in the UI bundle provides this component */
exportName: string;
/** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
}>;
};
}
Rules:
id must be globally uniqueid should normally equal the npm package nameapiVersion must match the host-supported plugin API versioncapabilities must be static and install-time visibleentrypoints.ui points to the directory containing the built UI bundleui.slots declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an exportName from the UI bundlePlugins may contribute tools that Paperclip agents can use during runs.
Plugins declare tools in their manifest:
tools?: Array<{
name: string;
displayName: string;
description: string;
parametersSchema: JsonSchema;
}>;
Tool names are automatically namespaced by plugin ID at runtime (e.g. linear:search-issues), so plugins cannot shadow core tools or each other's tools.
When an agent invokes a plugin tool during a run, the host routes the call to the plugin worker via a executeTool RPC method:
executeTool(input) — receives tool name, parsed parameters, and run context (agent ID, run ID, company ID, project ID)The worker executes the tool logic and returns a typed result. The host enforces capability gates — a plugin must declare agent.tools.register to contribute tools, and individual tools may require additional capabilities (e.g. http.outbound for tools that call external APIs).
By default, plugin tools are available to all agents. The operator may restrict tool availability per agent or per project through plugin configuration.
Plugin tools appear in the agent's tool list alongside core tools but are visually distinguished in the UI as plugin-contributed.
Third-party plugins run out-of-process by default.
Default runtime:
This design provides:
The host is responsible for:
The plugin worker is responsible for:
getData and performActionIf a worker fails:
errorWhen the host needs to stop a plugin worker (for upgrade, uninstall, or instance shutdown):
shutdown() to the worker.cancelled with a note indicating forced shutdown.getData or performAction calls return an error to the bridge.The shutdown deadline should be configurable per-plugin in plugin config for plugins that need longer drain periods.
The host must support the following worker RPC methods.
Required methods:
initialize(input)health()shutdown()Optional methods:
validateConfig(input)configChanged(input)onEvent(input)runJob(input)handleWebhook(input)getData(input)performAction(input)executeTool(input)initializeCalled once on worker startup.
Input includes:
healthReturns:
validateConfigRuns after config changes and startup.
Returns:
okconfigChangedCalled when the operator updates the plugin's instance config at runtime.
Input includes:
If the worker implements this method, it applies the new config without restarting. If the worker does not implement this method, the host restarts the worker process with the new config (graceful shutdown then restart).
onEventReceives one typed Paperclip domain event.
Delivery semantics:
runJobRuns a declared scheduled job.
The host provides:
handleWebhookReceives inbound webhook payload routed by the host.
The host provides:
getDataReturns plugin data requested by the plugin's own UI components.
The plugin UI calls the host bridge, which forwards the request to the worker. The worker returns typed JSON that the plugin's own frontend components render.
Input includes:
"sync-health", "issue-detail")performActionRuns an explicit plugin action initiated by the board UI.
Examples:
executeToolRuns a plugin-contributed agent tool during a run.
The host provides:
The worker executes the tool and returns a typed result (string content, structured data, or error).
Plugins do not talk to the DB directly. Plugins do not read raw secret material from persisted config.
The SDK exposed to workers must provide typed host clients.
Required SDK clients:
ctx.configctx.eventsctx.jobsctx.httpctx.secretsctx.assetsctx.activityctx.statectx.entitiesctx.projectsctx.issuesctx.agentsctx.goalsctx.datactx.actionsctx.toolsctx.loggerctx.data and ctx.actions register handlers that the plugin's own UI calls through the host bridge. ctx.data.register(key, handler) backs usePluginData(key) on the frontend. ctx.actions.register(key, handler) backs usePluginAction(key).
Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through ctx.projects so plugins can resolve workspace paths, but the host does not proxy low-level OS operations.
Trusted orchestration plugins can create and update Paperclip issues through ctx.issues instead of importing server internals. The public issue contract includes parent/project/goal links, board or agent assignees, blocker IDs, labels, billing code, request depth, execution workspace inheritance, and plugin origin metadata.
Origin rules:
manual and routine_execution.plugin:<pluginKey> or a sub-kind such as plugin:<pluginKey>:feature.plugin:<otherPluginKey> origins.originId is plugin-defined and should be stable for idempotent generated work.Relation and read helpers:
ctx.issues.relations.get(issueId, companyId)ctx.issues.relations.setBlockedBy(issueId, blockerIssueIds, companyId)ctx.issues.relations.addBlockers(issueId, blockerIssueIds, companyId)ctx.issues.relations.removeBlockers(issueId, blockerIssueIds, companyId)ctx.issues.getSubtree(issueId, companyId, options)ctx.issues.summaries.getOrchestration({ issueId, companyId, includeSubtree, billingCode })Governance helpers:
ctx.issues.assertCheckoutOwner({ issueId, companyId, actorAgentId, actorRunId }) lets plugin actions preserve agent-run checkout ownership.ctx.issues.requestWakeup(issueId, companyId, options) requests assignment wakeups through host heartbeat semantics, including terminal-status, blocker, assignee, and budget hard-stop checks.ctx.issues.requestWakeups(issueIds, companyId, options) applies the same host-owned wakeup semantics to a batch and may use an idempotency key prefix for stable coordinator retries.Plugin-originated issue, relation, document, comment, and wakeup mutations must write activity entries with actorType: "plugin" and details fields for sourcePluginId, sourcePluginKey, initiatingActorType, initiatingActorId, and initiatingRunId when a user or agent run initiated the plugin work.
Scoped API routes:
apiRoutes[] declares routeKey, method, plugin-local path, auth,
capability, optional checkout policy, and company resolution.api.routes.register, route matching,
and checkout policy before worker dispatch.onApiRequest(input) and returns a JSON response shape
{ status?, headers?, body? }./** Top-level helper for defining a plugin with type checking */
export function definePlugin(definition: PluginDefinition): PaperclipPlugin;
/** Re-exported from Zod for config schema definitions */
export { z } from "zod";
export interface PluginContext {
manifest: PaperclipPluginManifestV1;
config: {
get(): Promise<Record<string, unknown>>;
};
events: {
on(name: string, fn: (event: unknown) => Promise<void>): void;
on(name: string, filter: EventFilter, fn: (event: unknown) => Promise<void>): void;
emit(name: string, payload: unknown): Promise<void>;
};
jobs: {
register(key: string, input: { cron: string }, fn: (job: PluginJobContext) => Promise<void>): void;
};
state: {
get(input: ScopeKey): Promise<unknown | null>;
set(input: ScopeKey, value: unknown): Promise<void>;
delete(input: ScopeKey): Promise<void>;
};
entities: {
upsert(input: PluginEntityUpsert): Promise<void>;
list(input: PluginEntityQuery): Promise<PluginEntityRecord[]>;
};
data: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void;
};
actions: {
register(key: string, handler: (params: Record<string, unknown>) => Promise<unknown>): void;
};
tools: {
register(name: string, input: PluginToolDeclaration, fn: (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>): void;
};
logger: {
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
debug(message: string, meta?: Record<string, unknown>): void;
};
}
export interface EventFilter {
projectId?: string;
companyId?: string;
agentId?: string;
[key: string]: unknown;
}
Capabilities are mandatory and static. Every plugin declares them up front.
The host enforces capabilities in the SDK layer and refuses calls outside the granted set.
companies.readprojects.readproject.workspaces.readissues.readissue.comments.readissue.documents.readissue.relations.readissue.subtree.readagents.readgoals.readactivity.readcosts.readissues.orchestration.readissues.createissues.updateissue.comments.createissue.documents.writeissue.relations.writeissues.checkoutissues.wakeupassets.writeassets.readactivity.log.writemetrics.writeplugin.state.readplugin.state.writeevents.subscribeevents.emitjobs.schedulewebhooks.receivehttp.outboundsecrets.read-refagent.tools.registerinstance.settings.registerui.sidebar.registerui.page.registerui.detailTab.registerui.dashboardWidget.registerui.action.registerThe host must not expose capabilities for:
If a plugin upgrade adds capabilities:
upgrade_pendingready until approval completesThe host must emit typed domain events that plugins may subscribe to.
Minimum event set:
company.createdcompany.updatedproject.createdproject.updatedproject.workspace_createdproject.workspace_updatedproject.workspace_deletedissue.createdissue.updatedissue.comment.createdissue.document.createdissue.document.updatedissue.document.deletedissue.relations.updatedissue.checked_outissue.releasedissue.assignment_wakeup_requestedagent.createdagent.updatedagent.status_changedagent.run.startedagent.run.finishedagent.run.failedagent.run.cancelledapproval.createdapproval.decidedbudget.incident.openedbudget.incident.resolvedcost_event.createdactivity.loggedEach event must include:
Plugins may provide an optional filter when subscribing to events. The filter is evaluated by the host before dispatching to the worker, so filtered-out events never cross the process boundary.
Supported filter fields:
projectId — only receive events for a specific projectcompanyId — only receive events for a specific companyagentId — only receive events for a specific agentFilters are optional. If omitted, the plugin receives all events of the subscribed type. Filters may be combined (e.g. filter by both company and project).
Plugins may emit custom events using ctx.events.emit(name, payload). Plugin-emitted events use a namespaced event type: plugin.<pluginId>.<eventName>.
Other plugins may subscribe to these events using the same ctx.events.on() API:
ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => {
// react to the git plugin detecting a push
});
Rules:
events.emit capability.plugin. prefix).Plugins may declare scheduled jobs in their manifest.
Job rules:
job_key.Plugins may declare webhook endpoints in their manifest.
Webhook route shape:
POST /api/plugins/:pluginId/webhooks/:endpointKeyRules:
handleWebhook.Plugins ship their own frontend UI as a bundled React module. The host loads plugin UI into designated extension slots and provides a bridge for the plugin frontend to communicate with its own worker backend and with host APIs.
A plugin's dist/ui/ directory contains a built React bundle. The host serves this bundle and loads it into the page when the user navigates to a plugin surface (a plugin page, a detail tab, a dashboard widget, etc.).
The host provides, the plugin renders:
getData), call actions (via performAction), read host context (current company, project, entity), and use shared host UI primitives (design tokens, common components).Concrete example: a Linear plugin ships a dashboard widget.
The plugin's UI bundle exports:
// dist/ui/index.tsx
import { usePluginData, usePluginAction, MetricCard, StatusBadge } from "@paperclipai/plugin-sdk/ui";
export function DashboardWidget({ context }: PluginWidgetProps) {
const { data, loading } = usePluginData("sync-health", { companyId: context.companyId });
const resync = usePluginAction("resync");
if (loading) return <Spinner />;
return (
<div>
<MetricCard label="Synced Issues" value={data.syncedCount} trend={data.trend} />
{data.mappings.map(m => (
<StatusBadge key={m.id} label={m.label} status={m.status} />
))}
<button onClick={() => resync({ companyId: context.companyId })}>Resync Now</button>
</div>
);
}
What happens at runtime:
DashboardWidget export.DashboardWidget component into the dashboard widget slot, passing context (current company, user, etc.) and the bridge.usePluginData("sync-health", ...) calls through the bridge → host → plugin worker's getData RPC → returns JSON → the plugin component renders it however it wants.usePluginAction("resync") calls through the bridge → host → plugin worker's performAction RPC.What the host controls:
@paperclipai/plugin-sdk/ui so plugins can match the host's visual language without being forced to.What the plugin controls:
@paperclipai/plugin-sdk/ui)The SDK includes a ui subpath export that plugin frontends import. This subpath provides:
usePluginData(key, params), usePluginAction(key), useHostContext()MetricCard, StatusBadge, DataTable, LogView, ActionBar, Spinner, etc.PluginPageProps, PluginWidgetProps, PluginDetailTabPropsPlugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
Isolation rules:
@paperclipai/plugin-sdk/ui and their own dependencies.window.fetch or XMLHttpRequest directly for host API calls. All host communication goes through the bridge.import() of URLs outside the plugin's own bundle.If stronger isolation is needed later, the host can move to iframe-based mounting for untrusted plugins without changing the plugin's source code (the bridge API stays the same).
Plugin UI bundles must be pre-built ESM. The host does not compile or transform plugin UI code at runtime.
The host serves the plugin's dist/ui/ directory as static assets under a namespaced path:
/_plugins/:pluginId/ui/*When the host renders an extension slot, it dynamically imports the plugin's UI entry module from this path, resolves the named export declared in ui.slots[].exportName, and mounts it into the slot.
In development, the host may support a devUiUrl override in plugin config that points to a local dev server (e.g. Vite) so plugin authors can use hot-reload during development without rebuilding.
/settings/plugins/settings/plugins/:pluginIdThese routes are instance-level.
/:companyPrefix/plugins/:pluginIdThese routes exist because the board UI is organized around companies even though plugin installation is global.
Plugins may add tabs to:
Recommended route pattern:
/:companyPrefix/<entity>/:id?tab=<plugin-tab-id>Plugins may add cards or sections to the dashboard.
Plugins may add sidebar links to:
@paperclipai/plugin-sdk/uiThe host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement.
| Component | What it renders | Typical use |
|---|---|---|
MetricCard | Single number with label, optional trend/sparkline | KPIs, counts, rates |
StatusBadge | Inline status indicator (ok/warning/error/info) | Sync health, connection status |
DataTable | Rows and columns with optional sorting and pagination | Issue lists, job history, process lists |
TimeseriesChart | Line or bar chart with timestamped data points | Revenue trends, sync volume, error rates |
MarkdownBlock | Rendered markdown text | Descriptions, help text, notes |
KeyValueList | Label/value pairs in a definition-list layout | Entity metadata, config summary |
ActionBar | Row of buttons wired to usePluginAction | Resync, create branch, restart process |
LogView | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
JsonTree | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
Spinner | Loading indicator | Data fetch states |
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
The bridge hooks must return structured errors so plugin UI can handle failures gracefully.
usePluginData returns:
{
data: T | null;
loading: boolean;
error: PluginBridgeError | null;
}
usePluginAction returns an async function that either resolves with the result or throws a PluginBridgeError.
PluginBridgeError shape:
interface PluginBridgeError {
code: "WORKER_UNAVAILABLE" | "CAPABILITY_DENIED" | "WORKER_ERROR" | "TIMEOUT" | "UNKNOWN";
message: string;
/** Original error details from the worker, if available */
details?: unknown;
}
Error codes:
WORKER_UNAVAILABLE — the plugin worker is not running (crashed, shutting down, not yet started)CAPABILITY_DENIED — the plugin does not have the required capability for this operationWORKER_ERROR — the worker returned an error from its getData or performAction handlerTIMEOUT — the worker did not respond within the configured timeoutUNKNOWN — unexpected bridge-level failureThe @paperclipai/plugin-sdk/ui subpath should also export an ErrorBoundary component that plugin authors can use to catch rendering errors without crashing the host page.
Each plugin that declares an instanceConfigSchema in its manifest gets an auto-generated settings form at /settings/plugins/:pluginId. The host renders the form from the JSON Schema.
The auto-generated form supports:
"format": "secret-ref" renders as a secret picker that resolves through the Paperclip secret provider system rather than a plain text inputrequired, minLength, pattern, minimum, etc.)validateConfig RPC method — the host calls it and displays the result inlineFor plugins that need richer settings UX beyond what JSON Schema can express, the plugin may declare a settingsPage slot in ui.slots. When present, the host renders the plugin's own React component instead of the auto-generated form. The plugin component communicates with its worker through the standard bridge to read and write config.
Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards.
Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations.
The host provides workspace metadata through ctx.projects (list workspaces, get primary workspace, resolve workspace from issue or agent/run). Plugins use this metadata to resolve local paths and then operate on the filesystem, spawn processes, shell out to git, or open PTY sessions using standard Node APIs or any libraries they choose.
This keeps the host lean — it does not need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own logic for file browsing, git workflows, terminal sessions, and process management.
If data becomes part of the actual Paperclip product model, it should become a first-party table.
Examples:
project_workspaces is already first-partypluginsid uuid pkplugin_key text unique not nullpackage_name text not nullversion text not nullapi_version int not nullcategories text[] not nullmanifest_json jsonb not nullstatus enum: installed | ready | error | upgrade_pendinginstall_order int nullinstalled_at timestamptz not nullupdated_at timestamptz not nulllast_error text nullIndexes:
plugin_keystatusplugin_configid uuid pkplugin_id uuid fk plugins.id unique not nullconfig_json jsonb not nullcreated_at timestamptz not nullupdated_at timestamptz not nulllast_error text nullplugin_stateid uuid pkplugin_id uuid fk plugins.id not nullscope_kind enum: instance | company | project | project_workspace | agent | issue | goal | runscope_id uuid/text nullnamespace text not nullstate_key text not nullvalue_json jsonb not nullupdated_at timestamptz not nullConstraints:
(plugin_id, scope_kind, scope_id, namespace, state_key)Examples:
issueprojectproject_workspaceproject_workspaceproject_workspace or runplugin_jobsid uuid pkplugin_id uuid fk plugins.id not nullscope_kind enum nullablescope_id uuid/text nulljob_key text not nullschedule text nullstatus enum: idle | queued | running | errornext_run_at timestamptz nulllast_started_at timestamptz nulllast_finished_at timestamptz nulllast_succeeded_at timestamptz nulllast_error text nullConstraints:
(plugin_id, scope_kind, scope_id, job_key)plugin_job_runsid uuid pkplugin_job_id uuid fk plugin_jobs.id not nullplugin_id uuid fk plugins.id not nullstatus enum: queued | running | succeeded | failed | cancelledtrigger enum: schedule | manual | retrystarted_at timestamptz nullfinished_at timestamptz nullerror text nulldetails_json jsonb nullIndexes:
(plugin_id, started_at desc)(plugin_job_id, started_at desc)plugin_webhook_deliveriesid uuid pkplugin_id uuid fk plugins.id not nullscope_kind enum nullablescope_id uuid/text nullendpoint_key text not nullstatus enum: received | processed | failed | ignoredrequest_id text nullheaders_json jsonb nullbody_json jsonb nullreceived_at timestamptz not nullhandled_at timestamptz nullresponse_code int nullerror text nullIndexes:
(plugin_id, received_at desc)(plugin_id, endpoint_key, received_at desc)plugin_entities (optional but recommended)id uuid pkplugin_id uuid fk plugins.id not nullentity_type text not nullscope_kind enum not nullscope_id uuid/text nullexternal_id text nulltitle text nullstatus text nulldata_json jsonb not nullcreated_at timestamptz not nullupdated_at timestamptz not nullIndexes:
(plugin_id, entity_type, external_id) unique when external_id is not null(plugin_id, scope_kind, scope_id, entity_type)Use cases:
The activity log should extend actor_type to include plugin.
New actor enum:
agentusersystempluginPlugin-originated mutations should write:
actor_type = pluginactor_id = <plugin-id>sourcePluginId and sourcePluginKeyinitiatingActorType, initiatingActorId, and initiatingRunId when a user or agent run triggered the plugin workThe first plugin system does not allow arbitrary third-party migrations.
Later, if custom tables become necessary, the system may add a trusted-module-only migration path.
Plugin config must never persist raw secret values.
Rules:
All plugin-originated mutating actions must be auditable.
Minimum requirements:
pluginsGlobal plugin settings page must show:
Each plugin may expose:
instanceConfigSchemaRoute:
/settings/plugins/:pluginIdEach plugin may expose a company-context main page:
/:companyPrefix/plugins/:pluginIdThis page is where board users do most day-to-day work.
When a plugin is uninstalled, the host must handle plugin-owned data explicitly.
shutdown() to the worker and follows the graceful shutdown policy.uninstalled in the plugins table (soft delete).plugin_state, plugin_entities, plugin_jobs, plugin_job_runs, plugin_webhook_deliveries, plugin_config) is retained for a configurable grace period (default: 30 days).pnpm paperclipai plugin purge <plugin-id>.Plugin upgrades do not automatically migrate plugin state. If a plugin's value_json shape changes between versions:
value_json to detect and handle format changes.When upgrading a plugin:
shutdown() to the old worker.cancelled.upgrade_pending and the operator must approve before the new worker becomes ready.Plugin install, uninstall, upgrade, and config changes must take effect without restarting the Paperclip server. This is a normative requirement, not optional.
The architecture already supports this — plugins run as out-of-process workers with dynamic ESM imports, IPC bridges, and host-managed routing tables. This section makes the requirement explicit so implementations do not regress.
When a plugin is installed at runtime:
ready status (or upgrade_pending if capability approval is required).No other plugin or host service is interrupted.
When a plugin is uninstalled at runtime:
shutdown() and follows the graceful shutdown policy (Section 12.5).uninstalled and starts the data retention grace period (Section 25.1).No server restart is needed.
When a plugin is upgraded at runtime:
apiVersion is unchanged and no new capabilities are added, the upgrade completes without operator interaction.When an operator updates a plugin's instance config at runtime:
plugin_config.configChanged notification to the running worker via IPC.ctx.config and applies it without restarting. If the plugin needs to re-initialize connections (e.g. a new API token), it does so internally.configChanged, the host restarts the worker process with the new config (graceful shutdown then restart).The host must version plugin UI bundle URLs (e.g. /_plugins/:pluginId/ui/:version/* or content-hash-based paths) so that browser caches do not serve stale bundles after upgrade or reinstall.
The host should emit a plugin.ui.updated event that the frontend listens for to trigger re-import of updated plugin modules without a full page reload.
The host's plugin process manager must support:
Each worker process is independent. There is no shared process pool or batch restart mechanism.
Plugin workers use ctx.logger to emit structured logs. The host captures these logs and stores them in a queryable format.
Log storage rules:
plugin_logs table or appended to a log file under the plugin's data directory.stdout and stderr from the worker process as fallback logs even if the worker does not use ctx.logger.The plugin settings page must show:
The host should emit internal events when plugin health degrades. These use the plugin.* namespace (not core domain events) and do not appear in the core activity log:
plugin.health.degraded — worker reporting errors or failing health checksplugin.health.recovered — worker recovered from error stateplugin.worker.crashed — worker process exited unexpectedlyplugin.worker.restarted — worker restarted after crashThese events can be consumed by other plugins (e.g. a notification plugin) or surfaced in the dashboard.
@paperclipai/plugin-test-harnessThe host should publish a test harness package that plugin authors use for local development and testing.
The test harness provides:
ctx.config, ctx.events, ctx.state, etc.)getData and performAction calls as if coming from the UI bridgeexecuteTool calls as if coming from an agent runExample usage:
import { createTestHarness } from "@paperclipai/plugin-test-harness";
import manifest from "../dist/manifest.js";
import { register } from "../dist/worker.js";
const harness = createTestHarness({ manifest, capabilities: manifest.capabilities });
await register(harness.ctx);
// Simulate an event
await harness.emit("issue.created", { issueId: "iss-1", projectId: "proj-1" });
// Verify state was written
const state = await harness.state.get({ pluginId: manifest.id, scopeKind: "issue", scopeId: "iss-1", namespace: "sync", stateKey: "external-id" });
expect(state).toBeDefined();
// Simulate a UI data request
const data = await harness.getData("sync-health", { companyId: "comp-1" });
expect(data.syncedCount).toBeGreaterThan(0);
For developing a plugin against a running Paperclip instance:
pnpm paperclipai plugin install ./path/to/plugindevUiUrl in plugin config can point to a local Vite dev server for UI hot-reload.The host should publish a starter template (create-paperclip-plugin) that scaffolds:
package.json with correct paperclipPlugin keysDashboardWidget using bridge hooks.gitignore and tsconfig.jsonThis spec directly supports the following plugin types:
@paperclip/plugin-workspace-files@paperclip/plugin-terminal@paperclip/plugin-git@paperclip/plugin-linear@paperclip/plugin-github-issues@paperclip/plugin-grafana@paperclip/plugin-runtime-processes@paperclip/plugin-stripeapiVersion.The host publishes a single SDK package for plugin authors:
@paperclipai/plugin-sdk — the complete plugin SDKThe package uses subpath exports to separate worker and UI concerns:
@paperclipai/plugin-sdk — worker-side SDK (context, events, state, tools, logger, definePlugin, z)@paperclipai/plugin-sdk/ui — frontend SDK (bridge hooks, shared components, design tokens)A single package simplifies dependency management for plugin authors — one dependency, one version, one changelog. The subpath exports keep bundle separation clean: worker code imports from the root, UI code imports from /ui. Build tools tree-shake accordingly so the worker bundle does not include React components and the UI bundle does not include worker-only code.
Versioning rules:
apiVersion. When @paperclipai/[email protected] ships, it targets apiVersion: 2. Plugins built with SDK 1.x continue to declare apiVersion: 1.apiVersion simultaneously. This means plugins built against the previous SDK major version continue to work without modification. The host maintains separate IPC protocol handlers for each supported API version.sdkVersion in the manifest as a semver range (e.g. ">=1.4.0 <2.0.0"). The host validates this at install time and warns if the plugin's declared range is outside the host's supported SDK versions.apiVersion ships, the previous version enters a deprecation period of at least 6 months. During this period:
The host should publish a compatibility matrix:
| Host Version | Supported API Versions | SDK Range |
|---|---|---|
| 1.0 | 1 | 1.x |
| 2.0 | 1, 2 | 1.x, 2.x |
| 3.0 | 2, 3 | 2.x, 3.x |
This matrix is published in the host docs and queryable via GET /api/plugins/compatibility.
When a new SDK version is released:
@paperclipai/plugin-sdk dependency.apiVersion and sdkVersion in the manifest.plugins, plugin_config, plugin_state, plugin_jobs, plugin_job_runs, plugin_webhook_deliveries@paperclipai/plugin-sdk/uiPluginBridgeError)instanceConfigSchemaplugin.<pluginId>.* namespace)@paperclipai/plugin-test-harnesscreate-paperclip-plugin starter templateThis phase is enough for:
Workspace plugins (file browser, terminal, git, process tracking) do not require additional host APIs — they resolve workspace paths through ctx.projects and handle filesystem, git, PTY, and process operations directly.
plugin_entitiesPaperclip should not implement a generic in-process hook bag modeled directly after local coding tools.
Paperclip should implement:
That is the complete target design for the Paperclip plugin system.