doc/plugins/PLUGIN_AUTHORING_GUIDE.md
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
It is intentionally narrower than PLUGIN_SPEC.md. The spec includes future ideas; this guide only covers the alpha surface that exists now.
/api/plugins/:pluginId/api/*.@paperclipai/plugin-sdk/ui; use it for common Paperclip controls before
building custom versions.ctx.assets is not supported in the current runtime.Use the scaffold package:
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
For a plugin that lives outside the Paperclip repo:
pnpm --filter @paperclipai/create-paperclip-plugin build
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
--output /absolute/path/to/plugin-repos \
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
That creates a package with:
src/manifest.tssrc/worker.tssrc/ui/index.tsxtests/plugin.spec.tsesbuild.config.mjsrollup.config.mjsInside this monorepo, the scaffold uses workspace:* for @paperclipai/plugin-sdk.
Outside this monorepo, the scaffold snapshots @paperclipai/plugin-sdk from the local Paperclip checkout into a .paperclip-sdk/ tarball so you can build and test a plugin without publishing anything to npm first.
From the generated plugin folder:
pnpm install
pnpm typecheck
pnpm test
pnpm build
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
Example:
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
Worker:
ctx.dbapiRoutesplugin:<pluginKey> origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summariesFirst-party or otherwise trusted orchestration plugins can declare:
database: {
migrationsDir: "migrations",
coreReadTables: ["issues"],
}
Required capabilities are database.namespace.migrate and
database.namespace.read; add database.namespace.write for runtime mutations.
The host derives ctx.db.namespace, runs SQL files in filename order before the
worker starts, records checksums in plugin_migrations, and rejects changed
already-applied migrations.
Migration SQL may create or alter objects only inside ctx.db.namespace. It may
reference whitelisted public core tables for foreign keys or read-only views,
but may not mutate/alter/drop/truncate public tables, create extensions,
triggers, untrusted languages, or runtime multi-statement SQL. Runtime
ctx.db.query() is restricted to SELECT; runtime ctx.db.execute() is
restricted to namespace-local INSERT, UPDATE, and DELETE.
Plugins can expose JSON-only routes under their own namespace:
apiRoutes: [
{
routeKey: "initialize",
method: "POST",
path: "/issues/:issueId/smoke",
auth: "board-or-agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" },
},
]
The host resolves the plugin, checks that it is ready, enforces
api.routes.register, matches the declared method/path, resolves company access,
and applies checkout policy before dispatching to the worker's onApiRequest
handler. The worker receives sanitized headers, route params, query, parsed JSON
body, actor context, and company id. Do not use plugin routes to claim core
paths; they always remain under /api/plugins/:pluginId/api/*.
Plugins that provide durable Paperclip business objects should declare them in the manifest and let the host create or relink the actual records per company. Do this for plugin-owned agents, plugin-owned projects, and recurring automation. Do not hide long-lived work behind private plugin state when it should be visible to the board, scoped to a company, audited, budgeted, and assigned like normal Paperclip work.
Use these surfaces:
agents[] and require
agents.managed. Use this when the plugin provides a named worker the board
should see in the org, budget, pause, invoke, and inspect. Managed agents are
normal Paperclip agents with plugin ownership metadata, not background plugin
workers.projects[] and require
projects.managed. Use this when the plugin needs a stable company-scoped
project for its issues, routines, or workspace-oriented UI. Keep plugin work
in a project instead of scattering generated issues across unrelated projects.routines[] and require
routines.managed. Use this for scheduled, webhook, or manually triggered
jobs that should create visible Paperclip issues. Prefer managed routines over
plugin jobs[] for recurring business work; plugin jobs are for plugin
runtime maintenance that does not need a board-visible task trail.Managed resources are resolved by stable plugin keys, not hardcoded database
ids. In a worker action or data handler, call ctx.agents.managed.reconcile(),
ctx.projects.managed.reconcile(), and ctx.routines.managed.reconcile() for
the current companyId. reconcile() creates the missing resource, relinks a
recoverable binding, or returns the existing resource. reset() reapplies the
manifest defaults when the operator wants to restore the plugin's suggested
configuration.
Declare dependencies between managed resources with refs. A routine can point
at a managed agent through assigneeRef and at a managed project through
projectRef. Reconcile the referenced agent and project before reconciling the
routine; if a ref is still missing, the routine resolution reports
missing_refs instead of guessing.
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "example.research-plugin",
apiVersion: 1,
version: "0.1.0",
displayName: "Research Plugin",
description: "Creates a managed research agent and scheduled research routine.",
author: "Example",
categories: ["automation"],
capabilities: [
"agents.managed",
"projects.managed",
"routines.managed",
"instance.settings.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
agents: [
{
agentKey: "researcher",
displayName: "Researcher",
role: "research",
title: "Research Agent",
capabilities: "Runs recurring research briefs for this company.",
adapterPreference: ["codex_local", "claude_local", "process"],
instructions: {
content: "Follow the Paperclip heartbeat and produce concise research briefs.",
},
},
],
projects: [
{
projectKey: "research",
displayName: "Research",
description: "Recurring research work created by the Research Plugin.",
status: "in_progress",
},
],
routines: [
{
routineKey: "weekly-brief",
title: "Weekly research brief",
description: "Create a short research brief for the board.",
assigneeRef: { resourceKind: "agent", resourceKey: "researcher" },
projectRef: { resourceKind: "project", resourceKey: "research" },
priority: "medium",
triggers: [
{
kind: "schedule",
label: "Monday morning",
cronExpression: "0 9 * * 1",
timezone: "America/Chicago",
enabled: false,
},
],
},
],
ui: {
slots: [
{
type: "settingsPage",
id: "settings",
displayName: "Research",
exportName: "SettingsPage",
},
],
},
};
export default manifest;
In the worker, expose a small setup action or settings-page action that reconciles the resources for the selected company:
import { definePlugin } from "@paperclipai/plugin-sdk";
export default definePlugin({
setup(ctx) {
ctx.actions.register("setup-company", async (params) => {
const companyId = String(params.companyId ?? "");
if (!companyId) throw new Error("companyId is required");
const project = await ctx.projects.managed.reconcile("research", companyId);
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
return { project, agent, routine };
});
},
});
Authoring rules:
agentKey, projectKey, or
routineKey creates a new managed resource from the host's point of view.ctx.agents.invoke() or
ctx.agents.sessions only after you have a real agent id, either selected by
the operator or resolved from ctx.agents.managed.ctx.projects.UI:
usePluginDatausePluginActionusePluginStreamusePluginToastuseHostContext@paperclipai/plugin-sdk/uiMount surfaces currently wired in the host include:
pagesettingsPagedashboardWidgetsidebarsidebarPaneldetailTabtaskDetailViewprojectSidebarItemglobalToolbarButtontoolbarButtoncontextMenuItemcommentAnnotationcommentContextMenuItemUse shared components from @paperclipai/plugin-sdk/ui when the plugin needs a
Paperclip-native control. The host owns the implementation, so plugins inherit
the board's current styling, ordering, recent selections, and dark-mode behavior
without importing ui/src internals.
Currently exposed components include:
MarkdownBlock and MarkdownEditor for rendered and editable markdown.FileTree for serializable file and directory trees.IssuesList for a native company-scoped issue table.AssigneePicker for the same agent/user selector used in the new issue pane.
Use the controlled value format agent:<id>, user:<id>, or "".ProjectPicker for the same project selector used in the new issue pane.
Use the controlled project id value, or "" for no project.ManagedRoutinesList for plugin-owned routine settings pages.import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
export function PluginAssignmentControls({ companyId }: { companyId: string }) {
const [assignee, setAssignee] = useState("");
const [projectId, setProjectId] = useState("");
return (
<>
<AssigneePicker
companyId={companyId}
value={assignee}
onChange={(value) => setAssignee(value)}
/>
<ProjectPicker
companyId={companyId}
value={projectId}
onChange={setProjectId}
/>
</>
);
}
Plugin UI often needs to render a file tree, accept a folder path, or browse a project workspace. There are three different surfaces for that, and they map to different trust and data-flow boundaries. Pick the surface that matches the data the plugin actually has.
FileTreeUse FileTree from @paperclipai/plugin-sdk/ui whenever the plugin only needs
to render a serializable file/directory list and react to selection or
expand/collapse. The host owns the implementation, so plugin UI inherits the
board's icons, indent, focus ring, and dark-mode styling without importing host
internals.
import {
FileTree,
type FileTreeNode,
} from "@paperclipai/plugin-sdk/ui";
const nodes: FileTreeNode[] = [
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
{
name: "wiki",
path: "wiki",
kind: "dir",
children: [
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
],
},
];
export function WikiTree() {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["wiki"]));
const [selected, setSelected] = useState<string | null>(null);
return (
<FileTree
nodes={nodes}
selectedFile={selected}
expandedPaths={expanded}
onSelectFile={(path) => setSelected(path)}
onToggleDir={(path) =>
setExpanded((current) => {
const next = new Set(current);
next.has(path) ? next.delete(path) : next.add(path);
return next;
})
}
/>
);
}
Good fits:
packages/plugins/plugin-llm-wiki builds a
FileTreeNode[] from worker query results and renders it through FileTree.plugin-file-browser-example lazily fetches a directory's
children through a loadFileList action when onToggleDir fires, then
merges the children into the local tree state — letting the shared component
handle rendering and selection.Boundary rules:
nodes, expandedPaths, checkedPaths,
fileBadges, fileTones). Do not pass arbitrary render functions across the
plugin/host boundary in v1; the supported escape hatches are
fileBadges (status pill keyed by path) and fileTones (row tone keyed by
path).FileTree.tsx or any ui/src/* module. The SDK
declaration is the only supported import path for plugin UI.FileTree is for rendering and selection. Plugin-specific editors,
ingest flows, query forms, and lint runs stay inside the plugin and do not
belong as FileTree props.localFoldersWhen the plugin needs operator-configured filesystem roots — typically for
trusted local plugins like wiki tooling — declare localFolders[] on the
manifest and add the local.folders capability. The host renders a settings
surface for the operator to set the absolute path, validates the path
server-side (containment, symlinks, required files/directories), and exposes
ctx.localFolders.readText() and ctx.localFolders.writeTextAtomic() in the
worker.
export const manifest = {
capabilities: ["local.folders"],
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
requiredDirectories: ["sources", "pages"],
requiredFiles: ["schema.md"],
},
],
};
Use this when:
Do not use localFolders to grant the UI direct browser-side access to the
filesystem — there is no such capability. The browser still goes through the
worker via getData / performAction, and the worker only exposes paths it
chose to expose.
When the data lives inside an existing project workspace, keep the browsing flow worker-mediated:
ctx.projects.listWorkspaces() to resolve the workspace
path, then reads its filesystem with normal Node APIs.getData handler for the root listing and an action
for lazy children, then renders them through FileTree.The example plugin-file-browser-example is the reference for this pattern:
the worker registers fileList (data) and loadFileList (action) over the
same handler, and the UI uses the action for on-toggle directory loading so the
shared FileTree stays the rendering surface.
A single plugin can use more than one of these. The LLM Wiki uses
localFolders for its content root, then renders the resulting page list
through FileTree. The file browser example uses ctx.projects.listWorkspaces
to pick a workspace and renders its on-disk tree through FileTree with lazy
loading. Pick the boundary per data source, not per plugin.
Plugins may declare a page slot with routePath to own a company route like:
/:companyPrefix/<routePath>
Rules:
routePath must be a single lowercase slugAt minimum:
pnpm --filter <your-plugin-package> typecheck
pnpm --filter <your-plugin-package> test
pnpm --filter <your-plugin-package> build
If you changed host integration too, also run:
pnpm -r typecheck
pnpm test:run
pnpm build