@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 callssetup(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
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,
npmavailable 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
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 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.<pluginId>.<eventName> (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.
- Capability: Add
jobs.scheduletomanifest.capabilities. - Declare jobs in
manifest.jobs: each entry hasjobKey,displayName, optionaldescription, andschedule(a 5-field cron expression). - Register a handler in
setup()withctx.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:
// 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():
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:<key>:<slotId>. 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
import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui";
export function DashboardWidget() {
const { data } = usePluginData<{ status: string }>("health");
const ping = usePluginAction("ping");
return (
<div>
<MetricCard label="Health" value={data?.status ?? "unknown"} />
<button onClick={() => void ping()}>Ping</button>
</div>
);
}
Hooks reference
usePluginData<T>(key, params?)
Fetches data from the worker's registered getData handler. Re-fetches when params changes. Returns { data, loading, error, refresh }.
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<SyncStatus>("sync-status", {
companyId: context.companyId,
});
if (loading) return <Spinner />;
if (error) return <StatusBadge label={error.message} status="error" />;
return (
<div>
<StatusBadge label={data!.healthy ? "Healthy" : "Unhealthy"} status={data!.healthy ? "ok" : "error"} />
<p>Synced {data!.syncedCount} items</p>
<p>Last sync: {data!.lastSyncAt}</p>
<button onClick={refresh}>Refresh</button>
</div>
);
}
usePluginAction(key)
Returns an async function that calls the worker's performAction handler. Throws PluginBridgeError on failure.
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<string | null>(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 (
<div>
<button onClick={handleClick} disabled={busy}>
{busy ? "Syncing..." : "Resync Now"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
useHostContext()
Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
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 <p>No linked Linear issue.</p>;
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
}
usePluginStream<T>(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 }.
import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
interface ChatToken {
text: string;
}
export function ChatMessages({ context }: PluginWidgetProps) {
const { events, connected, close } = usePluginStream<ChatToken>("chat-stream", {
companyId: context.companyId ?? undefined,
});
return (
<div>
{events.map((e, i) => <span key={i}>{e.text}</span>)}
{connected && <span className="pulse" />}
<button onClick={close}>Stop</button>
</div>
);
}
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.
<MetricCard label="Issues Synced" value={142} unit="issues" trend={{ direction: "up", percentage: 12 }} />
<MetricCard label="API Latency" value="45ms" sparkline={[52, 48, 45, 47, 45]} />
StatusBadge
Inline status indicator with semantic color.
<StatusBadge label="Connected" status="ok" />
<StatusBadge label="Rate Limited" status="warning" />
<StatusBadge label="Auth Failed" status="error" />
DataTable
Sortable, paginated table.
<DataTable
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "status", header: "Status", width: "100px" },
{ key: "updatedAt", header: "Updated", render: (v) => new Date(v as string).toLocaleDateString() },
]}
rows={issues}
totalCount={totalCount}
page={page}
pageSize={25}
onPageChange={setPage}
onSort={(key, dir) => setSortBy({ key, dir })}
/>
TimeseriesChart
Line or bar chart for time-series data.
<TimeseriesChart
title="Sync Frequency"
data={[
{ timestamp: "2026-03-01T00:00:00Z", value: 24 },
{ timestamp: "2026-03-02T00:00:00Z", value: 31 },
{ timestamp: "2026-03-03T00:00:00Z", value: 28 },
]}
type="bar"
yLabel="Syncs"
height={250}
/>
ActionBar
Row of action buttons wired to the plugin bridge.
<ActionBar
actions={[
{ label: "Sync Now", actionKey: "sync", variant: "primary" },
{ label: "Clear Cache", actionKey: "clear-cache", confirm: true, confirmMessage: "Delete all cached data?" },
]}
onSuccess={(key) => data.refresh()}
onError={(key, err) => console.error(key, err)}
/>
LogView, JsonTree, KeyValueList, MarkdownBlock
<LogView entries={logEntries} maxHeight="300px" autoScroll />
<JsonTree data={debugPayload} defaultExpandDepth={3} />
<KeyValueList pairs={[{ label: "Plugin ID", value: pluginId }, { label: "Version", value: "1.2.0" }]} />
<MarkdownBlock content="**Bold** text and `code` blocks are supported." />
Spinner, ErrorBoundary
<Spinner size="lg" label="Loading plugin data..." />
<ErrorBoundary fallback={<p>Something went wrong.</p>} onError={(err) => console.error(err)}>
<MyPluginContent />
</ErrorBoundary>
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:
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<Record<string, string>>("agent-metrics", {
agentId: context.entityId,
companyId: context.companyId,
});
if (loading) return <Spinner />;
if (!data) return <p>No metrics available.</p>;
return (
<KeyValueList
pairs={Object.entries(data).map(([label, value]) => ({ 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:
{
"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:
{
"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):
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 (
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
Files
</a>
);
}
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.
{
"ui": {
"slots": [
{
"type": "toolbarButton",
"id": "sync-toolbar-button",
"displayName": "Sync",
"exportName": "SyncToolbarButton"
}
]
},
"capabilities": ["ui.action.register"]
}
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<string | null>(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 (
<ErrorBoundary>
<button type="button" onClick={() => setOpen(true)}>
Sync
</button>
{open ? (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => !submitting && setOpen(false)}
>
<div
className="w-full max-w-md rounded-lg bg-background p-4 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-base font-semibold">Sync this project?</h2>
<p className="mt-2 text-sm text-muted-foreground">
Queue a sync for <code>{context.projectId}</code>.
</p>
{errorMessage ? (
<p className="mt-2 text-sm text-destructive">{errorMessage}</p>
) : null}
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)}>
Cancel
</button>
<button type="button" onClick={() => void confirm()} disabled={submitting}>
{submitting ? <Spinner size="sm" /> : "Run sync"}
</button>
</div>
</div>
</div>
) : null}
</ErrorBoundary>
);
}
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:
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 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:
- Worker emits
streams.emitnotification via stdout - Host (
plugin-worker-manager) receives the notification and publishes toPluginStreamBus - 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:
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:
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<AgentEvent>(`agent:${agentId}`, { companyId });
const [prompt, setPrompt] = useState("");
async function send() {
setPrompt("");
await askAgent({ agentId, companyId, prompt });
}
return (
<div>
<div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button onClick={send}>Send</button>
{connected && <button onClick={close}>Stop</button>}
</div>
);
}
Agent sessions (two-way chat)
Plugins can hold multi-turn conversational sessions with agents:
// 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
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
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)
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
Or programmatically:
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
const server = await startPluginDevServer({ rootDir: process.cwd() });
Dev server endpoints:
GET /__paperclip__/healthreturns{ ok, rootDir, uiDir }GET /__paperclip__/eventsstreamsreloadSSE events on UI build changes