@paperclipai/plugin-sdk 2026.3.17-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +888 -0
  3. package/dist/bundlers.d.ts +57 -0
  4. package/dist/bundlers.d.ts.map +1 -0
  5. package/dist/bundlers.js +105 -0
  6. package/dist/bundlers.js.map +1 -0
  7. package/dist/define-plugin.d.ts +218 -0
  8. package/dist/define-plugin.d.ts.map +1 -0
  9. package/dist/define-plugin.js +85 -0
  10. package/dist/define-plugin.js.map +1 -0
  11. package/dist/dev-cli.d.ts +3 -0
  12. package/dist/dev-cli.d.ts.map +1 -0
  13. package/dist/dev-cli.js +49 -0
  14. package/dist/dev-cli.js.map +1 -0
  15. package/dist/dev-server.d.ts +34 -0
  16. package/dist/dev-server.d.ts.map +1 -0
  17. package/dist/dev-server.js +194 -0
  18. package/dist/dev-server.js.map +1 -0
  19. package/dist/host-client-factory.d.ts +229 -0
  20. package/dist/host-client-factory.d.ts.map +1 -0
  21. package/dist/host-client-factory.js +353 -0
  22. package/dist/host-client-factory.js.map +1 -0
  23. package/dist/index.d.ts +84 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +84 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/protocol.d.ts +881 -0
  28. package/dist/protocol.d.ts.map +1 -0
  29. package/dist/protocol.js +297 -0
  30. package/dist/protocol.js.map +1 -0
  31. package/dist/testing.d.ts +63 -0
  32. package/dist/testing.d.ts.map +1 -0
  33. package/dist/testing.js +700 -0
  34. package/dist/testing.js.map +1 -0
  35. package/dist/types.d.ts +982 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +12 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/ui/components.d.ts +257 -0
  40. package/dist/ui/components.d.ts.map +1 -0
  41. package/dist/ui/components.js +97 -0
  42. package/dist/ui/components.js.map +1 -0
  43. package/dist/ui/hooks.d.ts +120 -0
  44. package/dist/ui/hooks.d.ts.map +1 -0
  45. package/dist/ui/hooks.js +148 -0
  46. package/dist/ui/hooks.js.map +1 -0
  47. package/dist/ui/index.d.ts +50 -0
  48. package/dist/ui/index.d.ts.map +1 -0
  49. package/dist/ui/index.js +48 -0
  50. package/dist/ui/index.js.map +1 -0
  51. package/dist/ui/runtime.d.ts +3 -0
  52. package/dist/ui/runtime.d.ts.map +1 -0
  53. package/dist/ui/runtime.js +30 -0
  54. package/dist/ui/runtime.js.map +1 -0
  55. package/dist/ui/types.d.ts +308 -0
  56. package/dist/ui/types.d.ts.map +1 -0
  57. package/dist/ui/types.js +17 -0
  58. package/dist/ui/types.js.map +1 -0
  59. package/dist/worker-rpc-host.d.ts +127 -0
  60. package/dist/worker-rpc-host.d.ts.map +1 -0
  61. package/dist/worker-rpc-host.js +941 -0
  62. package/dist/worker-rpc-host.js.map +1 -0
  63. package/package.json +88 -0
package/README.md ADDED
@@ -0,0 +1,888 @@
1
+ # `@paperclipai/plugin-sdk`
2
+
3
+ Official TypeScript SDK for Paperclip plugin authors.
4
+
5
+ - **Worker SDK:** `@paperclipai/plugin-sdk` — `definePlugin`, context, lifecycle
6
+ - **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks and slot props
7
+ - **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
8
+ - **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
9
+ - **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
10
+
11
+ Reference: `doc/plugins/PLUGIN_SPEC.md`
12
+
13
+ ## Package surface
14
+
15
+ | Import | Purpose |
16
+ |--------|--------|
17
+ | `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
18
+ | `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
19
+ | `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
20
+ | `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
21
+ | `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
22
+ | `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
23
+ | `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
24
+ | `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |
25
+ | `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) |
26
+
27
+ ## Manifest entrypoints
28
+
29
+ In your plugin manifest you declare:
30
+
31
+ - **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.
32
+ - **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pnpm add @paperclipai/plugin-sdk
38
+ ```
39
+
40
+ ## Current deployment caveats
41
+
42
+ The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
43
+
44
+ - Plugin workers and plugin UI should both be treated as trusted code today.
45
+ - Plugin UI bundles run as same-origin JavaScript inside the main Paperclip app. They can call ordinary Paperclip HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.
46
+ - Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
47
+ - For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
48
+ - The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
49
+ - 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.
50
+ - The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
51
+ - `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
52
+
53
+ 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.
54
+
55
+ ## Worker quick start
56
+
57
+ ```ts
58
+ import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
59
+
60
+ const plugin = definePlugin({
61
+ async setup(ctx) {
62
+ ctx.events.on("issue.created", async (event) => {
63
+ ctx.logger.info("Issue created", { issueId: event.entityId });
64
+ });
65
+
66
+ ctx.data.register("health", async () => ({ status: "ok" }));
67
+ ctx.actions.register("ping", async () => ({ pong: true }));
68
+
69
+ ctx.tools.register("calculator", {
70
+ displayName: "Calculator",
71
+ description: "Basic math",
72
+ parametersSchema: {
73
+ type: "object",
74
+ properties: { a: { type: "number" }, b: { type: "number" } },
75
+ required: ["a", "b"]
76
+ }
77
+ }, async (params) => {
78
+ const { a, b } = params as { a: number; b: number };
79
+ return { content: `Result: ${a + b}`, data: { result: a + b } };
80
+ });
81
+ },
82
+ });
83
+
84
+ export default plugin;
85
+ runWorker(plugin, import.meta.url);
86
+ ```
87
+
88
+ **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.
89
+
90
+ ### Worker lifecycle and context
91
+
92
+ **Lifecycle (definePlugin):**
93
+
94
+ | Hook | Purpose |
95
+ |------|--------|
96
+ | `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |
97
+ | `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |
98
+ | `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |
99
+ | `onShutdown?()` | Optional. Clean up before process exit (limited time window). |
100
+ | `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
101
+ | `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
102
+
103
+ **Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
104
+
105
+ **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.
106
+
107
+ **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`.
108
+
109
+ ## Events
110
+
111
+ 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`).
112
+
113
+ **Core domain events (subscribe with `events.subscribe`):**
114
+
115
+ | Event | Typical entity |
116
+ |-------|-----------------|
117
+ | `company.created`, `company.updated` | company |
118
+ | `project.created`, `project.updated` | project |
119
+ | `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
120
+ | `issue.created`, `issue.updated`, `issue.comment.created` | issue |
121
+ | `agent.created`, `agent.updated`, `agent.status_changed` | agent |
122
+ | `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
123
+ | `goal.created`, `goal.updated` | goal |
124
+ | `approval.created`, `approval.decided` | approval |
125
+ | `cost_event.created` | cost |
126
+ | `activity.logged` | activity |
127
+
128
+ **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.
129
+
130
+ **Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
131
+
132
+ **Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
133
+
134
+ ## Scheduled (recurring) jobs
135
+
136
+ Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.
137
+
138
+ 1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.
139
+ 2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).
140
+ 3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.
141
+
142
+ **Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):
143
+
144
+ | Field | Values | Example |
145
+ |-------------|----------|---------|
146
+ | minute | 0–59 | `0`, `*/15` |
147
+ | hour | 0–23 | `2`, `*` |
148
+ | day of month | 1–31 | `1`, `*` |
149
+ | month | 1–12 | `*` |
150
+ | day of week | 0–6 (Sun=0) | `*`, `1-5` |
151
+
152
+ Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00.
153
+
154
+ **Job handler context** (`PluginJobContext`):
155
+
156
+ | Field | Type | Description |
157
+ |-------------|----------|-------------|
158
+ | `jobKey` | string | Matches the manifest declaration. |
159
+ | `runId` | string | UUID for this run. |
160
+ | `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. |
161
+ | `scheduledAt` | string | ISO 8601 time when the run was scheduled. |
162
+
163
+ 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.
164
+
165
+ Example:
166
+
167
+ **Manifest** — include `jobs.schedule` and declare the job:
168
+
169
+ ```ts
170
+ // In your manifest (e.g. manifest.ts):
171
+ const manifest = {
172
+ // ...
173
+ capabilities: ["jobs.schedule", "plugin.state.write"],
174
+ jobs: [
175
+ {
176
+ jobKey: "heartbeat",
177
+ displayName: "Heartbeat",
178
+ description: "Runs every 5 minutes",
179
+ schedule: "*/5 * * * *",
180
+ },
181
+ ],
182
+ // ...
183
+ };
184
+ ```
185
+
186
+ **Worker** — register the handler in `setup()`:
187
+
188
+ ```ts
189
+ ctx.jobs.register("heartbeat", async (job) => {
190
+ ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
191
+ await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
192
+ });
193
+ ```
194
+
195
+ ## UI slots and launchers
196
+
197
+ 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`).
198
+
199
+ ### Slot types / launcher placement zones
200
+
201
+ The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
202
+
203
+ | Slot type / placement zone | Scope | Entity types (when context-sensitive) |
204
+ |----------------------------|-------|---------------------------------------|
205
+ | `page` | Global | — |
206
+ | `sidebar` | Global | — |
207
+ | `sidebarPanel` | Global | — |
208
+ | `settingsPage` | Global | — |
209
+ | `dashboardWidget` | Global | — |
210
+ | `globalToolbarButton` | Global | — |
211
+ | `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
212
+ | `taskDetailView` | Entity | (task/issue context) |
213
+ | `commentAnnotation` | Entity | `comment` |
214
+ | `commentContextMenuItem` | Entity | `comment` |
215
+ | `projectSidebarItem` | Entity | `project` |
216
+ | `toolbarButton` | Entity | varies by host surface |
217
+ | `contextMenuItem` | Entity | varies by host surface |
218
+
219
+ **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).
220
+
221
+ **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`.
222
+
223
+ ### Slot component descriptions
224
+
225
+ #### `page`
226
+
227
+ A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-context route). 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.
228
+
229
+ #### `sidebar`
230
+
231
+ 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.
232
+
233
+ #### `sidebarPanel`
234
+
235
+ 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.
236
+
237
+ #### `settingsPage`
238
+
239
+ 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`).
240
+
241
+ #### `dashboardWidget`
242
+
243
+ 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.
244
+
245
+ #### `detailTab`
246
+
247
+ 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.
248
+
249
+ #### `taskDetailView`
250
+
251
+ 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.
252
+
253
+ #### `projectSidebarItem`
254
+
255
+ 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.
256
+
257
+ #### `globalToolbarButton`
258
+
259
+ A button rendered in the global top bar (breadcrumb bar) that appears on every page. Use this for company-wide actions that are not scoped to a specific entity — for example, a universal search trigger, a global sync status indicator, or a floating action that applies across the whole workspace. Receives only `context.companyId` and `context.companyPrefix`; no entity context is available. Requires the `ui.action.register` capability.
260
+
261
+ #### `toolbarButton`
262
+
263
+ A button rendered in the toolbar of an entity page (e.g. project detail, issue detail). Use this for short-lived, contextual actions scoped to the current entity — like triggering a project sync, opening a picker, or running a quick command on that entity. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId`, `context.entityId`, and `context.entityType`; declare `entityTypes` in the manifest to control which entity pages the button appears on. Requires the `ui.action.register` capability.
264
+
265
+ #### `contextMenuItem`
266
+
267
+ 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.
268
+
269
+ #### `commentAnnotation`
270
+
271
+ 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.
272
+
273
+ #### `commentContextMenuItem`
274
+
275
+ 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.
276
+
277
+ ### Launcher actions and render options
278
+
279
+ | Launcher action | Description |
280
+ |-----------------|-------------|
281
+ | `navigate` | Navigate to a route (plugin or host). |
282
+ | `openModal` | Open a modal. |
283
+ | `openDrawer` | Open a drawer. |
284
+ | `openPopover` | Open a popover. |
285
+ | `performAction` | Run an action (e.g. call plugin). |
286
+ | `deepLink` | Deep link to plugin or external URL. |
287
+
288
+ | Render option | Values | Description |
289
+ |---------------|--------|-------------|
290
+ | `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |
291
+ | `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |
292
+
293
+ ### Capabilities
294
+
295
+ Declare in `manifest.capabilities`. Grouped by scope:
296
+
297
+ | Scope | Capability |
298
+ |-------|------------|
299
+ | **Company** | `companies.read` |
300
+ | | `projects.read` |
301
+ | | `project.workspaces.read` |
302
+ | | `issues.read` |
303
+ | | `issue.comments.read` |
304
+ | | `agents.read` |
305
+ | | `goals.read` |
306
+ | | `goals.create` |
307
+ | | `goals.update` |
308
+ | | `activity.read` |
309
+ | | `costs.read` |
310
+ | | `issues.create` |
311
+ | | `issues.update` |
312
+ | | `issue.comments.create` |
313
+ | | `activity.log.write` |
314
+ | | `metrics.write` |
315
+ | **Instance** | `instance.settings.register` |
316
+ | | `plugin.state.read` |
317
+ | | `plugin.state.write` |
318
+ | **Runtime** | `events.subscribe` |
319
+ | | `events.emit` |
320
+ | | `jobs.schedule` |
321
+ | | `webhooks.receive` |
322
+ | | `http.outbound` |
323
+ | | `secrets.read-ref` |
324
+ | **Agent** | `agent.tools.register` |
325
+ | | `agents.invoke` |
326
+ | | `agent.sessions.create` |
327
+ | | `agent.sessions.list` |
328
+ | | `agent.sessions.send` |
329
+ | | `agent.sessions.close` |
330
+ | **UI** | `ui.sidebar.register` |
331
+ | | `ui.page.register` |
332
+ | | `ui.detailTab.register` |
333
+ | | `ui.dashboardWidget.register` |
334
+ | | `ui.commentAnnotation.register` |
335
+ | | `ui.action.register` |
336
+
337
+ Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
338
+
339
+ ## UI quick start
340
+
341
+ ```tsx
342
+ import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
343
+
344
+ export function DashboardWidget() {
345
+ const { data } = usePluginData<{ status: string }>("health");
346
+ const ping = usePluginAction("ping");
347
+ return (
348
+ <div style={{ display: "grid", gap: 8 }}>
349
+ <strong>Health</strong>
350
+ <div>{data?.status ?? "unknown"}</div>
351
+ <button onClick={() => void ping()}>Ping</button>
352
+ </div>
353
+ );
354
+ }
355
+ ```
356
+
357
+ ### Hooks reference
358
+
359
+ #### `usePluginData<T>(key, params?)`
360
+
361
+ Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
362
+
363
+ ```tsx
364
+ import { usePluginData } from "@paperclipai/plugin-sdk/ui";
365
+
366
+ interface SyncStatus {
367
+ lastSyncAt: string;
368
+ syncedCount: number;
369
+ healthy: boolean;
370
+ }
371
+
372
+ export function SyncStatusWidget({ context }: PluginWidgetProps) {
373
+ const { data, loading, error, refresh } = usePluginData<SyncStatus>("sync-status", {
374
+ companyId: context.companyId,
375
+ });
376
+
377
+ if (loading) return <div>Loading…</div>;
378
+ if (error) return <div>Error: {error.message}</div>;
379
+
380
+ return (
381
+ <div>
382
+ <p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
383
+ <p>Synced {data!.syncedCount} items</p>
384
+ <p>Last sync: {data!.lastSyncAt}</p>
385
+ <button onClick={refresh}>Refresh</button>
386
+ </div>
387
+ );
388
+ }
389
+ ```
390
+
391
+ #### `usePluginAction(key)`
392
+
393
+ Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
394
+
395
+ ```tsx
396
+ import { useState } from "react";
397
+ import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
398
+
399
+ export function ResyncButton({ context }: PluginWidgetProps) {
400
+ const resync = usePluginAction("resync");
401
+ const [busy, setBusy] = useState(false);
402
+ const [error, setError] = useState<string | null>(null);
403
+
404
+ async function handleClick() {
405
+ setBusy(true);
406
+ setError(null);
407
+ try {
408
+ await resync({ companyId: context.companyId });
409
+ } catch (err) {
410
+ setError((err as PluginBridgeError).message);
411
+ } finally {
412
+ setBusy(false);
413
+ }
414
+ }
415
+
416
+ return (
417
+ <div>
418
+ <button onClick={handleClick} disabled={busy}>
419
+ {busy ? "Syncing..." : "Resync Now"}
420
+ </button>
421
+ {error && <p style={{ color: "red" }}>{error}</p>}
422
+ </div>
423
+ );
424
+ }
425
+ ```
426
+
427
+ #### `useHostContext()`
428
+
429
+ Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
430
+
431
+ ```tsx
432
+ import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
433
+ import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
434
+
435
+ export function IssueLinearLink({ context }: PluginDetailTabProps) {
436
+ const { companyId, entityId, entityType } = context;
437
+ const { data } = usePluginData<{ url: string }>("linear-link", {
438
+ companyId,
439
+ issueId: entityId,
440
+ });
441
+
442
+ if (!data?.url) return <p>No linked Linear issue.</p>;
443
+ return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
444
+ }
445
+ ```
446
+
447
+ #### `usePluginStream<T>(channel, options?)`
448
+
449
+ 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 }`.
450
+
451
+ ```tsx
452
+ import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
453
+
454
+ interface ChatToken {
455
+ text: string;
456
+ }
457
+
458
+ export function ChatMessages({ context }: PluginWidgetProps) {
459
+ const { events, connected, close } = usePluginStream<ChatToken>("chat-stream", {
460
+ companyId: context.companyId ?? undefined,
461
+ });
462
+
463
+ return (
464
+ <div>
465
+ {events.map((e, i) => <span key={i}>{e.text}</span>)}
466
+ {connected && <span className="pulse" />}
467
+ <button onClick={close}>Stop</button>
468
+ </div>
469
+ );
470
+ }
471
+ ```
472
+
473
+ The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
474
+
475
+ ### UI authoring note
476
+
477
+ The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
478
+
479
+ ### Slot component props
480
+
481
+ Each slot type receives a typed props object with `context: PluginHostContext`. Import from `@paperclipai/plugin-sdk/ui`.
482
+
483
+ | Slot type | Props interface | `context` extras |
484
+ |-----------|----------------|------------------|
485
+ | `page` | `PluginPageProps` | — |
486
+ | `sidebar` | `PluginSidebarProps` | — |
487
+ | `settingsPage` | `PluginSettingsPageProps` | — |
488
+ | `dashboardWidget` | `PluginWidgetProps` | — |
489
+ | `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
490
+ | `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
491
+ | `toolbarButton` | `PluginToolbarButtonProps` | `entityId: string`, `entityType: string` |
492
+ | `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
493
+ | `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
494
+ | `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
495
+
496
+ Example detail tab with entity context:
497
+
498
+ ```tsx
499
+ import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
500
+ import { usePluginData } from "@paperclipai/plugin-sdk/ui";
501
+
502
+ export function AgentMetricsTab({ context }: PluginDetailTabProps) {
503
+ const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
504
+ agentId: context.entityId,
505
+ companyId: context.companyId,
506
+ });
507
+
508
+ if (loading) return <div>Loading…</div>;
509
+ if (!data) return <p>No metrics available.</p>;
510
+
511
+ return (
512
+ <dl>
513
+ {Object.entries(data).map(([label, value]) => (
514
+ <div key={label}>
515
+ <dt>{label}</dt>
516
+ <dd>{value}</dd>
517
+ </div>
518
+ ))}
519
+ </dl>
520
+ );
521
+ }
522
+ ```
523
+
524
+ ## Launcher surfaces and modals
525
+
526
+ V1 does not provide a dedicated `modal` slot. Plugins can either:
527
+
528
+ - declare concrete UI mount points in `ui.slots`
529
+ - declare host-rendered entry points in `ui.launchers`
530
+
531
+ Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `globalToolbarButton`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
532
+
533
+ Declarative launcher example:
534
+
535
+ ```json
536
+ {
537
+ "ui": {
538
+ "launchers": [
539
+ {
540
+ "id": "sync-project",
541
+ "displayName": "Sync",
542
+ "placementZone": "toolbarButton",
543
+ "entityTypes": ["project"],
544
+ "action": {
545
+ "type": "openDrawer",
546
+ "target": "sync-project"
547
+ },
548
+ "render": {
549
+ "environment": "hostOverlay",
550
+ "bounds": "wide"
551
+ }
552
+ }
553
+ ]
554
+ }
555
+ }
556
+ ```
557
+
558
+ The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.
559
+
560
+ When a launcher opens a host-owned overlay or page, `useHostContext()`,
561
+ `usePluginData()`, and `usePluginAction()` receive the current
562
+ `renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.
563
+ full-page layouts without adding custom route parsing in the plugin.
564
+
565
+ ## Project sidebar item
566
+
567
+ 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:
568
+
569
+ ```json
570
+ {
571
+ "ui": {
572
+ "slots": [
573
+ {
574
+ "type": "projectSidebarItem",
575
+ "id": "files",
576
+ "displayName": "Files",
577
+ "exportName": "FilesLink",
578
+ "entityTypes": ["project"]
579
+ }
580
+ ]
581
+ },
582
+ "capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
583
+ }
584
+ ```
585
+
586
+ Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
587
+
588
+ ```tsx
589
+ import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
590
+
591
+ export function FilesLink({ context }: PluginProjectSidebarItemProps) {
592
+ const projectId = context.entityId;
593
+ const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
594
+ const projectRef = projectId; // or resolve from host; entityId is project id
595
+ return (
596
+ <a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
597
+ Files
598
+ </a>
599
+ );
600
+ }
601
+ ```
602
+
603
+ 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.
604
+
605
+ ## Toolbar launcher with a local modal
606
+
607
+ Two toolbar slot types are available depending on where the button should appear:
608
+
609
+ - **`globalToolbarButton`** — renders in the top bar on every page, scoped to the company. No entity context. Use for workspace-wide actions.
610
+ - **`toolbarButton`** — renders on entity detail pages (project, issue, etc.). Receives `entityId` and `entityType`. Declare `entityTypes` to control which pages the button appears on.
611
+
612
+ For short-lived actions, mount the appropriate slot type and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or entity.
613
+
614
+ Project-scoped example (appears only on project detail pages):
615
+
616
+ ```json
617
+ {
618
+ "ui": {
619
+ "slots": [
620
+ {
621
+ "type": "toolbarButton",
622
+ "id": "sync-toolbar-button",
623
+ "displayName": "Sync",
624
+ "exportName": "SyncToolbarButton",
625
+ "entityTypes": ["project"]
626
+ }
627
+ ]
628
+ },
629
+ "capabilities": ["ui.action.register"]
630
+ }
631
+ ```
632
+
633
+ ```tsx
634
+ import { useState } from "react";
635
+ import {
636
+ useHostContext,
637
+ usePluginAction,
638
+ } from "@paperclipai/plugin-sdk/ui";
639
+
640
+ export function SyncToolbarButton() {
641
+ const context = useHostContext();
642
+ const syncProject = usePluginAction("sync-project");
643
+ const [open, setOpen] = useState(false);
644
+ const [submitting, setSubmitting] = useState(false);
645
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
646
+
647
+ async function confirm() {
648
+ if (!context.projectId) return;
649
+ setSubmitting(true);
650
+ setErrorMessage(null);
651
+ try {
652
+ await syncProject({ projectId: context.projectId });
653
+ setOpen(false);
654
+ } catch (err) {
655
+ setErrorMessage(err instanceof Error ? err.message : "Sync failed");
656
+ } finally {
657
+ setSubmitting(false);
658
+ }
659
+ }
660
+
661
+ return (
662
+ <>
663
+ <button type="button" onClick={() => setOpen(true)}>
664
+ Sync
665
+ </button>
666
+ {open ? (
667
+ <div
668
+ role="dialog"
669
+ aria-modal="true"
670
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
671
+ onClick={() => !submitting && setOpen(false)}
672
+ >
673
+ <div
674
+ className="w-full max-w-md rounded-lg bg-background p-4 shadow-xl"
675
+ onClick={(event) => event.stopPropagation()}
676
+ >
677
+ <h2 className="text-base font-semibold">Sync this project?</h2>
678
+ <p className="mt-2 text-sm text-muted-foreground">
679
+ Queue a sync for <code>{context.projectId}</code>.
680
+ </p>
681
+ {errorMessage ? (
682
+ <p className="mt-2 text-sm text-destructive">{errorMessage}</p>
683
+ ) : null}
684
+ <div className="mt-4 flex justify-end gap-2">
685
+ <button type="button" onClick={() => setOpen(false)}>
686
+ Cancel
687
+ </button>
688
+ <button type="button" onClick={() => void confirm()} disabled={submitting}>
689
+ {submitting ? "Running…" : "Run sync"}
690
+ </button>
691
+ </div>
692
+ </div>
693
+ </div>
694
+ ) : null}
695
+ </>
696
+ );
697
+ }
698
+ ```
699
+
700
+ Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
701
+
702
+ ## Real-time streaming (`ctx.streams`)
703
+
704
+ 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.
705
+
706
+ ### Worker side
707
+
708
+ In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
709
+
710
+ ```ts
711
+ const plugin = definePlugin({
712
+ async setup(ctx) {
713
+ ctx.actions.register("chat", async (params) => {
714
+ const companyId = params.companyId as string;
715
+ ctx.streams.open("chat-stream", companyId);
716
+
717
+ for await (const token of streamFromLLM(params.prompt as string)) {
718
+ ctx.streams.emit("chat-stream", { text: token });
719
+ }
720
+
721
+ ctx.streams.close("chat-stream");
722
+ return { ok: true };
723
+ });
724
+ },
725
+ });
726
+ ```
727
+
728
+ **API:**
729
+
730
+ | Method | Description |
731
+ |--------|-------------|
732
+ | `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
733
+ | `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
734
+ | `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
735
+
736
+ Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
737
+
738
+ ### UI side
739
+
740
+ Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
741
+
742
+ ### Host-side architecture
743
+
744
+ The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
745
+
746
+ 1. Worker emits `streams.emit` notification via stdout
747
+ 2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
748
+ 3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
749
+
750
+ The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
751
+
752
+ ### Streaming agent responses to the UI
753
+
754
+ `ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
755
+
756
+ ```
757
+ UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
758
+ UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
759
+ ```
760
+
761
+ 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.
762
+
763
+ **Worker:**
764
+
765
+ ```ts
766
+ ctx.actions.register("ask-agent", async (params) => {
767
+ const { agentId, companyId, prompt } = params as {
768
+ agentId: string; companyId: string; prompt: string;
769
+ };
770
+
771
+ const channel = `agent:${agentId}`;
772
+ ctx.streams.open(channel, companyId);
773
+
774
+ const session = await ctx.agents.sessions.create(agentId, companyId);
775
+
776
+ await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
777
+ prompt,
778
+ onEvent: (event) => {
779
+ ctx.streams.emit(channel, {
780
+ type: event.eventType, // "chunk" | "done" | "error"
781
+ text: event.message ?? "",
782
+ });
783
+ },
784
+ });
785
+
786
+ ctx.streams.close(channel);
787
+ return { sessionId: session.sessionId };
788
+ });
789
+ ```
790
+
791
+ **UI:**
792
+
793
+ ```tsx
794
+ import { useState } from "react";
795
+ import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
796
+
797
+ interface AgentEvent {
798
+ type: "chunk" | "done" | "error";
799
+ text: string;
800
+ }
801
+
802
+ export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
803
+ const askAgent = usePluginAction("ask-agent");
804
+ const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });
805
+ const [prompt, setPrompt] = useState("");
806
+
807
+ async function send() {
808
+ setPrompt("");
809
+ await askAgent({ agentId, companyId, prompt });
810
+ }
811
+
812
+ return (
813
+ <div>
814
+ <div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
815
+ <input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
816
+ <button onClick={send}>Send</button>
817
+ {connected && <button onClick={close}>Stop</button>}
818
+ </div>
819
+ );
820
+ }
821
+ ```
822
+
823
+ ## Agent sessions (two-way chat)
824
+
825
+ Plugins can hold multi-turn conversational sessions with agents:
826
+
827
+ ```ts
828
+ // Create a session
829
+ const session = await ctx.agents.sessions.create(agentId, companyId);
830
+
831
+ // Send a message and stream the response
832
+ await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
833
+ prompt: "Help me triage this issue",
834
+ onEvent: (event) => {
835
+ if (event.eventType === "chunk") console.log(event.message);
836
+ if (event.eventType === "done") console.log("Stream complete");
837
+ },
838
+ });
839
+
840
+ // List active sessions
841
+ const sessions = await ctx.agents.sessions.list(agentId, companyId);
842
+
843
+ // Close when done
844
+ await ctx.agents.sessions.close(session.sessionId, companyId);
845
+ ```
846
+
847
+ Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
848
+
849
+ Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
850
+
851
+ ## Testing utilities
852
+
853
+ ```ts
854
+ import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
855
+ import plugin from "../src/worker.js";
856
+ import manifest from "../src/manifest.js";
857
+
858
+ const harness = createTestHarness({ manifest });
859
+ await plugin.definition.setup(harness.ctx);
860
+ await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
861
+ ```
862
+
863
+ ## Bundler presets
864
+
865
+ ```ts
866
+ import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
867
+
868
+ const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
869
+ // presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
870
+ // presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
871
+ ```
872
+
873
+ ## Local dev server (hot-reload events)
874
+
875
+ ```bash
876
+ paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
877
+ ```
878
+
879
+ Or programmatically:
880
+
881
+ ```ts
882
+ import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
883
+ const server = await startPluginDevServer({ rootDir: process.cwd() });
884
+ ```
885
+
886
+ Dev server endpoints:
887
+ - `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
888
+ - `GET /__paperclip__/events` streams `reload` SSE events on UI build changes