mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents split work into child tasks, and each task runs in an execution workspace (a checkout, branch, or sandbox folder) > - Since early 2026, a new child task used its parent's realized workspace by default: the same checkout, branch and sandbox > - A parent and its child often run at the same time. Two writers in one folder lose each other's files, the waking parent is cancelled with `workspace_busy`, and sandbox lease ownership crosses from the parent into the child, so retained-file recovery refuses the workspace as ambiguous > - The product rule is simple: a subtask gets its own workspace, independent of the parent's saved files. Paperclip never needs to carry a parent's unmerged changes into a child > - This pull request makes that the only behaviour on every creation path, and moves existing shared children to their own workspace on their next run, with no user or admin action > - The benefit is that parents and children never block or overwrite each other, and recovery ownership stays with one task ## Linked Issues or Issue Description **What happened?** A child task inherited its parent's realized execution workspace by default (`createChild` defaulted to `executionWorkspaceInheritanceMode: "linkage"`, and `issueService.create` fell back from `inheritExecutionWorkspaceFromIssueId` to `parentId`). When the parent woke while the child wrote to the shared workspace, the parent run was cancelled with `workspace_busy`. In sandbox environments, the lease ownership chain crossed from the parent into the child, and retained-file recovery refused the workspace with `allocation_ownership_ambiguous`. **Expected behavior** Every new child task gets its own execution workspace, realized from the project base exactly as a top-level task in that project. It never uses the parent's checkout, branch, saved snapshot or unmerged changes. A parent and its descendant never share a workspace by any route. **Steps to reproduce** 1. Run a parent task in a project with isolated (git worktree) or sandbox workspaces, so the parent has a realized execution workspace. 2. From the parent run, create a child task with only `parentId` (or through the runner `create_task` tool, chat suggestions, decision effects, plugins, or the New sub-task dialog). 3. See that the child's `executionWorkspaceId` is the parent's, with `executionWorkspacePreference: "reuse_existing"`. 4. Wake the parent while the child runs in a sandbox environment. The parent run is cancelled with `workspace_busy`. **Additional context** Refs #9233 (the existing `strategy_only` opt-out). The opt-out `executionWorkspaceInheritanceMode: "strategy_only"` already existed for accepted-plan decomposition. This change makes that behaviour (tightened to drop a pinned existing branch) the only behaviour. ## What Changed - **One rule for every creation path (`server/src/services/issues.ts`).** `issueService.create` still uses the parent (or `inheritExecutionWorkspaceFromIssueId`) as a source for the project, project workspace and workspace *strategy*, but it never copies the source's `executionWorkspaceId`, `reuse_existing` preference, or pinned existing branch. All server paths that create a task go through `create()`/`createChild()`: issue routes (`POST /issues`, `POST /issues/:id/children`, the run-bound default parent and run workspace source), runner `create_task`, chat suggestion acceptance, native status decisions, accepted-plan decomposition, decision effects, plugins (`ctx.issues.create`), external agents, watchdog follow-ups, monitor recovery issues, case handoffs and email channels. - **Explicit ancestor workspaces are dropped.** If a create names an `executionWorkspaceId` (or an `existing` workspace selection) that an ancestor of the new task created or is bound to, `create()` drops it and keeps only the strategy. Older clients and skills that copied the parent's id keep working and get an independent workspace. - **`createChild` no longer has a "linkage" mode.** `executionWorkspaceInheritanceMode` is still accepted from internal callers and is ignored. - **`inheritExecutionWorkspaceFromIssueId` decision.** I kept the field for API compatibility, but it now copies only the source issue's project and workspace strategy. I removed its workspace-sharing behaviour for every pair, not only parent/child: the source task and its follow-up can run at the same time (the runner, watchdog and status-decision callers all create the follow-up while the source run is live), so sharing would again let two tasks write one folder. Explicit sharing between unrelated tasks stays available through an explicit `existing` workspace selection. - **New `server/src/services/task-workspace-lineage.ts`.** `buildIndependentTaskWorkspaceSettings` (strategy only: mode, environment, runtime, network egress, shared-workspace concurrency, base ref, branch template, commands; no `reuse_existing`, no `operator_branch`, no `existingBranch`), `loadTaskLineageIssueIds` (the parent chain plus, for a task that depth placement put beside its requester, the requester's chain via `privacyParentIssueId`) and `isAncestorExecutionWorkspace` (the workspace's `sourceIssueId` is in that lineage, or a lineage issue is bound to it). A child in another project does not take the parent's strategy. - **Upgrade-safe rebind at admission.** `executionWorkspaceService.releaseAncestorWorkspaceBindingAtAdmission` runs at the same admission point as the pending workspace selection (`heartbeat.ts`). For a child (or a flattened task beside its requester) that an older release bound to an ancestor's workspace, it clears the child's binding (keeps the project workspace and strategy, bumps the binding revision) so the run realizes the child's own workspace. It does nothing while another run of the child is running, while the child's earlier run still owns workspace finalization or recovery, for native restart/lease-owner recovery, for a process-loss retry, or for a Retry that keeps the current workspace. The parent and its workspace are never changed. - **Projects without a project workspace (per-task repository acquisition).** A parent with no project workspace worked in its own task files, so its settings (for example `isolated_workspace`) do not describe the project base. A child of such a parent, and an older child released from one, takes the project's defaults exactly like a top-level task, instead of an isolated mode that would resolve to a git worktree with no git base ("needs a project / project workspace"). When the child's new workspace is created at admission, `executionWorkspaceRepositoryService.prepareForAdmission` repeats the parent's (or requester's) repository requests as the child's own pending requests (identity, URL, catalog id and requested ref only; no pinned commit, branch or files). The normal acquisition path then clones a fresh copy into the child's task files with the child's own access checks. This runs only for a workspace created by that admission, so existing tasks never gain repositories. - **UI.** The New sub-task dialog no longer pre-selects the parent's workspace. `buildSubIssueDefaults` drops `executionWorkspaceId` and `reuse_existing`, and derives the parent's independent mode (`shared_workspace`, `isolated_workspace` or `agent_default`) from its preference, saved settings or current workspace. The "This sub-task will no longer use the parent task workspace" warning is removed. - **Contracts and docs.** Shared validator and plugin SDK comments describe the new meaning of `inheritExecutionWorkspaceFromIssueId`. Updated `doc/execution-semantics.md` (new rule in §6 and a wording fix in the retained-sandbox paragraph), `doc/SPEC-implementation.md` (child workspace rule, requester keeps *strategy* inheritance, watchdog follow-ups), `doc/SPEC.md` (task workspace ownership), `docs/specs/external-task-protocol.md`, `skills/paperclip/SKILL.md` (step 9 and the workspace bullet), `skills/paperclip/references/issue-workspaces.md`, the CEO onboarding `HEARTBEAT.md`, and the plugin SDK README. ### Previous-release story (`rules/upgrade-safe-run-state.mdc`) - **Migrated on next admission.** A child that the previous release bound to its parent's (or any ancestor's) execution workspace keeps that binding until its next normal admission. Then the server clears the binding and the run realizes the child's own workspace from the project base. No schema migration is needed. No user or admin action is needed. - **In-flight writers finish first.** A running child is not interrupted. Its current run, its native restart recovery (admitted native input or lease owner), a process-loss retry, and a Retry that keeps the current workspace all finish in the shared workspace. While a child's earlier run is still running or its workspace finalization or recovery is unsettled, the binding stays and a later admission releases it. - **No files are dropped.** The release changes only the child's binding row. The parent keeps its binding, and the shared workspace row, folder, branch and saved snapshots are not touched. Anything the child left there stays in the parent's workspace. - **No new refusal codes.** Saved run identity (native execution input, leases, checkpoints) is not changed or refused; the rebind only affects new admissions. ## Verification - New `server/src/__tests__/subtask-workspace-independence.test.ts` (embedded Postgres): `create` with `parentId`, `createChild` with no mode / `"linkage"` / `"strategy_only"`, `inheritExecutionWorkspaceFromIssueId` pointing at the parent, explicit parent and grandparent `executionWorkspaceId`, a non-child follow-up, and unchanged top-level defaults and explicit top-level reuse. 7 creation cases fail on `master` and pass with this change. Admission cases: an old-release child is rebound while its parent is running and the parent keeps its workspace; a running child is not rebound (deploy lands mid-run); an unrelated explicit share is kept; the parent is never rebound. - New heartbeat case in `server/src/__tests__/heartbeat-workspace-busy.test.ts`: an old-release child bound to its running parent's workspace in a sandbox environment runs to `succeeded` with no `workspace_busy`, gets a new workspace, and the parent run and binding stay unchanged. It fails on `master` (the child waits with `workspace_busy`). - Updated tests that asserted the old inheritance in `issues-service.test.ts`, `task-workspace-binding.test.ts`, `chat-project-tools.test.ts`, `task-workspace-child-trust.test.ts`, `heartbeat-git-restore-point-resume.test.ts` (a child bound to a closed parent's expired workspace now starts from the project base and leaves the parent's restore point alone), and `ui/src/lib/subIssueDefaults.test.ts`. Remote-workspace reservation, continuation and writer-wait suites now seed the older-release shared shape directly (or an unrelated explicit share), so those safety paths stay covered. - `npx vitest run` on the changed suites and about 45 related server suites (heartbeat workspace, recovery admission, issue routes, routines, plugins), `ui` `subIssueDefaults` and `NewIssueDialog` tests, `pnpm check:token-gates`, `pnpm -r typecheck`. - New `server/src/__tests__/heartbeat-subtask-acquired-repository.test.ts`: a project with no project workspace, a parent whose task files hold an acquired repository with an unmerged change, and a child (new, and the older-release shape bound to the parent's workspace) that runs to `succeeded` in its own task files with a fresh clone at the project base and its own ready repository receipt. The parent's checkout keeps its change. Both cases fail on the previous head with "needs a project / project workspace". ## Risks - **Project-level configuration still applies to children exactly as to top-level tasks.** In a project configured for `shared_workspace`, every task (top-level or child) uses the project's one folder under the existing shared-workspace concurrency policy. A branch that the project itself pins applies to every task in the project. This PR removes only the parent-to-child carry-over of the realized workspace; it does not change those project defaults. - **Standalone follow-ups that an older release bound through `inheritExecutionWorkspaceFromIssueId`** are migrated only when the source is in their recorded lineage (parent or privacy parent). Others keep their explicit share and the existing writer-wait serialization. - **Behaviour change for agents that relied on a shared checkout.** A child or follow-up no longer sees the parent's unpushed or uncommitted changes. Agents must push or merge code they want a subtask to build on. The skill and onboarding text now say this. - **Explicit ancestor workspace ids are silently dropped** instead of rejected, so older clients do not break. The task gets an independent workspace. - **First run of a migrated child starts from the project base.** If an older child had uncommitted work in the parent's workspace, that work stays in the parent's workspace and is not copied into the child's new workspace. - **Admission adds one indexed read** for tasks with a parent and a bound workspace; the locking transaction and ancestor query run only when the bound workspace was created by another issue. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through Claude Code with tool use (file edits, shell, test execution) and extended reasoning. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1569 lines
69 KiB
Markdown
1569 lines
69 KiB
Markdown
# `@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 and 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: hooks, host navigation, HTTP-safe clipboard copy, shared components, and slot prop types |
|
||
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
||
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
||
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
|
||
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
|
||
| `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |
|
||
| `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) |
|
||
|
||
## Manifest entrypoints
|
||
|
||
In your plugin manifest you declare:
|
||
|
||
- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.
|
||
- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
|
||
|
||
## Install
|
||
|
||
```bash
|
||
pnpm add @paperclipai/plugin-sdk
|
||
```
|
||
|
||
## Current deployment caveats
|
||
|
||
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
|
||
|
||
- Plugin workers and plugin UI should both be treated as trusted code today.
|
||
- 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.
|
||
- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
|
||
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
||
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
||
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
||
- The host ships a small shared React component kit through `@paperclipai/plugin-sdk/ui`. Use it for native Paperclip controls; custom React and CSS are still supported.
|
||
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
||
|
||
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.
|
||
|
||
## Runner ingress failures
|
||
|
||
Environment providers can throw `PluginEnvironmentRunnerIngressError` from
|
||
`onEnvironmentRunnerIngressEndpoint` with a validated SDK HTTP status and one
|
||
closed phase: `sandbox_lookup`, `sandbox_start`, `sandbox_refresh` or
|
||
`private_preview`. The worker forwards only this closed status/phase envelope.
|
||
It omits response bodies, URLs, headers and causes. The host independently
|
||
validates it with `readEnvironmentRunnerIngressFailure`. Do not derive a status
|
||
from error text. Unknown and configuration failures keep their normal errors.
|
||
The envelope alone grants no retry, accounting or cleanup authority; native
|
||
execution also requires its complete, current controller checkpoint proof.
|
||
|
||
## Stop failures
|
||
|
||
Environment providers can throw `PluginEnvironmentStopError` from
|
||
`onEnvironmentStopLease` when they cannot stop a resource that is in a provider
|
||
error state. The error takes one closed reason:
|
||
|
||
- `resource_unrecoverable`: the provider reports that the resource cannot recover.
|
||
- `recovery_failed`: the resource is in an error state again after one recovery attempt.
|
||
- `recovery_incomplete`: recovery did not finish before the stop deadline.
|
||
|
||
The worker forwards only the reason. It omits messages, provider error text and
|
||
causes. The host reads it with `readEnvironmentStopFailure`, which derives the
|
||
`terminal` flag from the reason. The first two reasons are terminal: another
|
||
automatic attempt cannot succeed without provider or operator action. The host
|
||
escalates a terminal failure at once and retries it at its slowest cleanup
|
||
interval. It never abandons the pending cleanup. Other stop errors keep their
|
||
normal retry behavior.
|
||
|
||
## Worker quick start
|
||
|
||
```ts
|
||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||
|
||
const plugin = definePlugin({
|
||
async setup(ctx) {
|
||
ctx.events.on("issue.created", async (event) => {
|
||
ctx.logger.info("Issue created", { issueId: event.entityId });
|
||
});
|
||
|
||
ctx.data.register("health", async () => ({ status: "ok" }));
|
||
ctx.actions.register("ping", async () => ({ pong: true }));
|
||
|
||
ctx.tools.register("calculator", {
|
||
displayName: "Calculator",
|
||
description: "Basic math",
|
||
parametersSchema: {
|
||
type: "object",
|
||
properties: { a: { type: "number" }, b: { type: "number" } },
|
||
required: ["a", "b"]
|
||
}
|
||
}, async (params) => {
|
||
const { a, b } = params as { a: number; b: number };
|
||
return { content: `Result: ${a + b}`, data: { result: a + b } };
|
||
});
|
||
},
|
||
});
|
||
|
||
export default plugin;
|
||
runWorker(plugin, import.meta.url);
|
||
```
|
||
|
||
**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting.
|
||
|
||
### Worker lifecycle and context
|
||
|
||
**Lifecycle (definePlugin):**
|
||
|
||
| Hook | Purpose |
|
||
|------|--------|
|
||
| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |
|
||
| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |
|
||
| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |
|
||
| `onShutdown?()` | Optional. Clean up before process exit (limited time window). |
|
||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||
|
||
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||
|
||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||
|
||
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
|
||
|
||
**Trusted local folders:** Declare `manifest.localFolders[]` and the `local.folders` capability when a plugin needs an operator-configured company-scoped folder. Use `ctx.localFolders.configure()`, `status()`, `readText()`, and `writeTextAtomic()` instead of resolving arbitrary filesystem paths yourself. The host validates absolute roots, read/write access, required relative folders/files, traversal attempts, symlink escapes, and writes through temp-file-plus-rename atomic replacement.
|
||
|
||
## 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 |
|
||
| `issue.document.created`, `issue.document.updated`, `issue.document.deleted` | issue |
|
||
| `issue.relations.updated`, `issue.checked_out`, `issue.released`, `issue.assignment_wakeup_requested` | 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 |
|
||
| `budget.incident.opened`, `budget.incident.resolved` | budget_incident |
|
||
| `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 context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope.
|
||
|
||
## Scheduled (recurring) jobs
|
||
|
||
Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.
|
||
|
||
1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.
|
||
2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).
|
||
3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.
|
||
|
||
**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):
|
||
|
||
| Field | Values | Example |
|
||
|-------------|----------|---------|
|
||
| minute | 0–59 | `0`, `*/15` |
|
||
| hour | 0–23 | `2`, `*` |
|
||
| day of month | 1–31 | `1`, `*` |
|
||
| month | 1–12 | `*` |
|
||
| day of week | 0–6 (Sun=0) | `*`, `1-5` |
|
||
|
||
Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00.
|
||
|
||
**Job handler context** (`PluginJobContext`):
|
||
|
||
| Field | Type | Description |
|
||
|-------------|----------|-------------|
|
||
| `jobKey` | string | Matches the manifest declaration. |
|
||
| `runId` | string | UUID for this run. |
|
||
| `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. |
|
||
| `scheduledAt` | string | ISO 8601 time when the run was scheduled. |
|
||
|
||
Runs can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.
|
||
|
||
Example:
|
||
|
||
**Manifest** — include `jobs.schedule` and declare the job:
|
||
|
||
```ts
|
||
// In your manifest (e.g. manifest.ts):
|
||
const manifest = {
|
||
// ...
|
||
capabilities: ["jobs.schedule", "plugin.state.write"],
|
||
jobs: [
|
||
{
|
||
jobKey: "heartbeat",
|
||
displayName: "Heartbeat",
|
||
description: "Runs every 5 minutes",
|
||
schedule: "*/5 * * * *",
|
||
},
|
||
],
|
||
// ...
|
||
};
|
||
```
|
||
|
||
**Worker** — register the handler in `setup()`:
|
||
|
||
```ts
|
||
ctx.jobs.register("heartbeat", async (job) => {
|
||
ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
|
||
await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
|
||
});
|
||
```
|
||
|
||
## UI slots and launchers
|
||
|
||
Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`).
|
||
|
||
### Slot types / launcher placement zones
|
||
|
||
Slot types describe where a component mounts. Most values also exist as launcher placement zones.
|
||
|
||
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|
||
|----------------------------|-------|---------------------------------------|
|
||
| `page` | Global | — |
|
||
| `sidebar` | Global | — |
|
||
| `routeSidebar` | Global | — |
|
||
| `sidebarPanel` | Global | — |
|
||
| `settingsPage` | Global | — |
|
||
| `dashboardWidget` | Global | — |
|
||
| `globalToolbarButton` | Global | — |
|
||
| `appShellOverlay` (slot only) | Signed-in application shell | — |
|
||
| `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-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.
|
||
|
||
#### `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.
|
||
|
||
#### `routeSidebar`
|
||
|
||
A contextual sidebar shown while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation. It does **not** replace the app sidebar: the host collapses the main `<Sidebar/>` to its 64px icon rail (still hover/peek-able) and renders your `routeSidebar` in a secondary pane beside it, producing `[ app rail ][ your sidebar ][ content ]`. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
|
||
|
||
Do **not** mount `RequestCollapsedSidebar` (or otherwise try to collapse the app sidebar) from a `routeSidebar` plugin — the host drives the collapse automatically while your route is active and restores the user's preference when they navigate away. The collapse is a hard invariant: a secondary sidebar always forces the app rail collapsed (hiding its expand toggle), overriding any user pin, but it never mutates the user's saved expanded/collapsed preference — that is restored as soon as they leave your route.
|
||
|
||
#### `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.
|
||
|
||
Agent detail tabs can also appear in task side panels. `taskSidePanel` makes the
|
||
view available for manual selection with the running or assigned agent. Manual
|
||
selection mounts the same component as the agent detail page, including while
|
||
the task is idle. An optional activity mapping also opens it automatically:
|
||
|
||
```ts
|
||
taskSidePanel: {
|
||
availabilityPath: "/agents/:entityId/view/availability",
|
||
activation: { namespace: "example.runtime", activityKey: "desktop.used" },
|
||
}
|
||
```
|
||
|
||
Declare the exact availability path as a board-only GET API route with
|
||
`companyResolution: { from: "agent", param: "entityId" }`. Core maps the declared
|
||
namespace/activity pair to installed agent detail tabs. Runner knows no plugin
|
||
or UI slot. Declarations without `activation` support manual task-panel selection.
|
||
Activity controls automatic opening and grants no access permission.
|
||
|
||
#### Runtime activity recording
|
||
|
||
A runtime client captures Runner's opaque activity context before an eligible
|
||
operation, then sends a structured notification after successful use:
|
||
|
||
```sh
|
||
context=$("$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" notification-context)
|
||
# Perform the operation; notify only on success.
|
||
"$PAPERCLIP_RUNTIME_NOTIFICATION_HELPER" record-activity "$context" example.runtime desktop.used
|
||
```
|
||
|
||
Runner supplies the matching absolute executable as
|
||
`PAPERCLIP_RUNTIME_NOTIFICATION_HELPER` and an atomic endpoint descriptor as
|
||
`PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT`. Configured task environment cannot
|
||
override them. Provider launch boundaries preserve both values. Codex receives
|
||
read access to the helper, descriptor and publisher lock, plus write access to
|
||
one named pipe. The pipe and lock live in a private temporary directory, outside
|
||
durable Runner state. The enclosing directories remain protected.
|
||
|
||
The helper captures context locally. After success, it publishes newline-delimited
|
||
JSON through a nonblocking pipe. A leading newline terminates an interrupted
|
||
previous write. A read-only file lock serializes publishers with a 500 ms deadline.
|
||
Runner reuses its bounded line reader: 2 KiB per record, 128 queued records and
|
||
16 records per control-loop iteration. Idle transport blocks; cleanup removes
|
||
only its pipe, lock and empty private directory.
|
||
|
||
A successful helper exit means publication to the pipe. Runner validates context
|
||
against the active run, deduplicates up to 128 activity pairs per authority, and
|
||
persists `runtime.activity.recorded` through its authenticated event stream.
|
||
Warm attachment and process recovery rotate context. The helper accepts no run
|
||
identity. Clients treat this hint as best effort, including with older runtimes.
|
||
Notify only after successful use; exclude help, schema discovery, status probes
|
||
and failures. No agent instruction is required.
|
||
|
||
Identifiers are 1–160 Unicode code points, excluding C0 controls and DEL.
|
||
Core maps declared activity identifiers to its consumers and applies their
|
||
existing discovery, authorization and availability checks.
|
||
|
||
Core reads durable activity on mount/reconnect and uses the existing live stream.
|
||
The authorized run binding identifies company, task and agent; caller payloads
|
||
cannot override them. Core replaces `:entityId` with the running agent ID, adds
|
||
`runId` and `issueId`, and requires `{ available: true }`. History/live hints merge
|
||
without losing earlier activity. Only matching current-task activity can open a
|
||
panel automatically.
|
||
Readiness and transient discovery failures get at most three attempts; authorization
|
||
failures are not retried. No periodic readiness polling is added. Dismissal stays
|
||
scoped to the current run. Only tab identity is stored in browser state; activity
|
||
remains in the authorized run log. Failed history reads, unavailable views and
|
||
access failures prevent automatic opening. Manual selection uses the viewer's
|
||
own authorization and status handling. Missing or disabled plugins add no view.
|
||
The plugin owns labels, content and controls. No private integration identifier
|
||
belongs in Core.
|
||
|
||
#### `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.
|
||
|
||
#### `globalToolbarButton`
|
||
|
||
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.
|
||
|
||
#### `toolbarButton`
|
||
|
||
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.
|
||
|
||
#### `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` |
|
||
| | `issue.documents.read` |
|
||
| | `issue.relations.read` |
|
||
| | `issue.subtree.read` |
|
||
| | `agents.read` |
|
||
| | `goals.read` |
|
||
| | `goals.create` |
|
||
| | `goals.update` |
|
||
| | `activity.read` |
|
||
| | `costs.read` |
|
||
| | `issues.orchestration.read` |
|
||
| | `access.members.read` |
|
||
| | `access.invites.read` |
|
||
| | `authorization.grants.read` |
|
||
| | `authorization.policies.read` |
|
||
| | `authorization.audit.read` |
|
||
| | `database.namespace.read` |
|
||
| | `issues.create` |
|
||
| | `issues.update` |
|
||
| | `issues.checkout` |
|
||
| | `issues.wakeup` |
|
||
| | `issue.comments.create` |
|
||
| | `issue.comments.create_human_attributed` |
|
||
| | `issue.documents.write` |
|
||
| | `issue.relations.write` |
|
||
| | `activity.log.write` |
|
||
| | `metrics.write` |
|
||
| | `telemetry.track` |
|
||
| | `database.namespace.migrate` |
|
||
| | `database.namespace.write` |
|
||
| | `external.objects.detect` |
|
||
| | `external.objects.read` |
|
||
| | `external.objects.write` |
|
||
| | `external.objects.refresh` |
|
||
| **Instance** | `instance.settings.register` |
|
||
| | `plugin.state.read` |
|
||
| | `plugin.state.write` |
|
||
| **Runtime** | `events.subscribe` |
|
||
| | `events.emit` |
|
||
| | `jobs.schedule` |
|
||
| | `webhooks.receive` |
|
||
| | `api.routes.register` |
|
||
| | `http.outbound` |
|
||
| | `secrets.read-ref` |
|
||
| | `environment.drivers.register` |
|
||
| | `local.folders` |
|
||
| **Agent** | `agent.tools.register` |
|
||
| | `agents.invoke` |
|
||
| | `access.members.write` |
|
||
| | `access.invites.write` |
|
||
| | `authorization.grants.write` |
|
||
| | `authorization.policies.write` |
|
||
| | `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`.
|
||
|
||
### External Object Reference Providers
|
||
|
||
Trusted connector plugins can declare generic external object providers in the
|
||
manifest. The host owns URL scanning, sanitized canonical URLs, core storage,
|
||
normalized status rendering, and issue/comment/document write durability. The
|
||
plugin only identifies provider-owned objects and resolves board-safe status
|
||
metadata.
|
||
|
||
```ts
|
||
objectReferences: [
|
||
{
|
||
providerKey: "mocktracker",
|
||
displayName: "Mock Tracker",
|
||
objectTypes: ["ticket"],
|
||
urlPatterns: ["https://mock.example/tickets/:id"],
|
||
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
|
||
},
|
||
],
|
||
capabilities: ["external.objects.detect", "external.objects.read"],
|
||
```
|
||
|
||
Implement `onDetectExternalObjects()` to map sanitized URL candidates to
|
||
`providerKey`, `objectType`, provider-stable `externalId`, and optional display
|
||
metadata such as `displayKey`/`iconKey`. Implement `onResolveExternalObject()`
|
||
to return a normalized snapshot with `statusCategory`, `statusTone`,
|
||
`statusLabel`, optional `statusIconKey`, board-safe `data`, and freshness
|
||
metadata. Slow or failing plugins are isolated: Paperclip logs the failure and
|
||
continues saving the source issue, comment, or document.
|
||
|
||
MVP security posture: provider plugins are trusted installs. Manifest
|
||
capabilities gate host APIs and provider invocation paths, but they are not a
|
||
sandbox boundary for untrusted marketplace code. Plugin UI is same-origin
|
||
JavaScript and must not be mounted inline in markdown; inline external-object
|
||
rendering uses host-owned metadata only. Treat untrusted providers as future work
|
||
that requires worker sandboxing plus isolated plugin UI.
|
||
|
||
### Restricted Database Namespace
|
||
|
||
Trusted orchestration plugins can declare a host-owned PostgreSQL namespace:
|
||
|
||
```ts
|
||
database: {
|
||
migrationsDir: "migrations",
|
||
coreReadTables: ["issues"],
|
||
}
|
||
```
|
||
|
||
Declare `database.namespace.migrate` and `database.namespace.read`; add
|
||
`database.namespace.write` when the worker needs runtime writes. Migrations run
|
||
before worker startup, are checksum-recorded, and may create or alter objects
|
||
only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from
|
||
`ctx.db.namespace` plus manifest-whitelisted `public` core tables. Runtime
|
||
`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the
|
||
plugin namespace.
|
||
|
||
### Trusted Local Folders
|
||
|
||
Trusted local plugins can request operator-configured folders per company:
|
||
|
||
```ts
|
||
export const manifest = {
|
||
// ...
|
||
capabilities: ["local.folders"],
|
||
localFolders: [
|
||
{
|
||
folderKey: "content-root",
|
||
displayName: "Content root",
|
||
access: "readWrite",
|
||
requiredDirectories: ["sources", "pages"],
|
||
requiredFiles: ["schema.md"],
|
||
},
|
||
],
|
||
};
|
||
```
|
||
|
||
The host stores the selected path in company-scoped plugin settings and exposes
|
||
readiness through:
|
||
|
||
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders`
|
||
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status`
|
||
- `POST /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate`
|
||
- `PUT /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey`
|
||
|
||
Worker code should access files through `ctx.localFolders.readText()` and
|
||
`ctx.localFolders.writeTextAtomic()`. Relative paths must stay inside the
|
||
configured root; symlinks that escape the root are rejected.
|
||
|
||
### Scoped API Routes
|
||
|
||
Manifest-declared `apiRoutes` expose JSON routes under
|
||
`/api/plugins/:pluginId/api/*` without letting a plugin claim core paths:
|
||
|
||
```ts
|
||
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" },
|
||
},
|
||
]
|
||
```
|
||
|
||
Implement `onApiRequest(input)` in the worker to handle the route. The host
|
||
performs auth, company access, capability, route matching, and checkout policy
|
||
before dispatch. The worker receives route params, query, parsed JSON body,
|
||
sanitized headers, actor context, and `companyId`; responses are JSON `{ status?,
|
||
headers?, body? }`.
|
||
|
||
## Issue Orchestration APIs
|
||
|
||
Workflow plugins can use `ctx.issues` for orchestration-grade issue operations without importing host server internals.
|
||
|
||
A child issue always gets its own execution workspace, realized from the project base. It keeps the parent's project and workspace strategy, never the parent's checkout, branch or unmerged changes. `inheritExecutionWorkspaceFromIssueId` likewise copies only the source issue's project and workspace strategy.
|
||
|
||
Expanded create/update fields include blockers, billing code, board or agent assignees, labels, namespaced plugin origins, request depth, and safe execution workspace fields:
|
||
|
||
```ts
|
||
const child = await ctx.issues.create({
|
||
companyId,
|
||
parentId: missionIssueId,
|
||
title: "Implement feature slice",
|
||
status: "todo",
|
||
assigneeAgentId: workerAgentId,
|
||
billingCode: "mission:alpha",
|
||
originKind: "plugin:paperclip.missions:feature",
|
||
originId: "mission-alpha:feature-1",
|
||
blockedByIssueIds: [planningIssueId],
|
||
});
|
||
```
|
||
|
||
If `originKind` is omitted, the host stores `plugin:<pluginKey>`. Plugins may use sub-kinds such as `plugin:<pluginKey>:feature`, but the host rejects attempts to set another plugin's namespace.
|
||
|
||
Blocker relationships are also exposed as first-class helpers:
|
||
|
||
```ts
|
||
const relations = await ctx.issues.relations.get(child.id, companyId);
|
||
await ctx.issues.relations.setBlockedBy(child.id, [planningIssueId], companyId);
|
||
await ctx.issues.relations.addBlockers(child.id, [validationIssueId], companyId);
|
||
await ctx.issues.relations.removeBlockers(child.id, [planningIssueId], companyId);
|
||
```
|
||
|
||
Subtree reads can include just the issue tree, or compact related data for orchestration dashboards:
|
||
|
||
```ts
|
||
const subtree = await ctx.issues.getSubtree(missionIssueId, companyId, {
|
||
includeRoot: true,
|
||
includeRelations: true,
|
||
includeDocuments: true,
|
||
includeActiveRuns: true,
|
||
includeAssignees: true,
|
||
});
|
||
```
|
||
|
||
Agent-run actions can assert checkout ownership before mutating in-progress work:
|
||
|
||
```ts
|
||
await ctx.issues.assertCheckoutOwner({
|
||
issueId,
|
||
companyId,
|
||
actorAgentId: runCtx.agentId,
|
||
actorRunId: runCtx.runId,
|
||
});
|
||
```
|
||
|
||
Plugins can request assignment wakeups through the host so budget stops, execution locks, blocker checks, and heartbeat policy still apply:
|
||
|
||
```ts
|
||
await ctx.issues.requestWakeup(child.id, companyId, {
|
||
reason: "mission_advance",
|
||
contextSource: "missions.advance",
|
||
});
|
||
|
||
await ctx.issues.requestWakeups([featureIssueId, validationIssueId], companyId, {
|
||
reason: "mission_advance",
|
||
contextSource: "missions.advance",
|
||
idempotencyKeyPrefix: `mission:${missionIssueId}:advance`,
|
||
});
|
||
```
|
||
|
||
Use `ctx.issues.summaries.getOrchestration()` when a workflow needs compact reads across a root issue or subtree:
|
||
|
||
```ts
|
||
const summary = await ctx.issues.summaries.getOrchestration({
|
||
issueId: missionIssueId,
|
||
companyId,
|
||
includeSubtree: true,
|
||
billingCode: "mission:alpha",
|
||
});
|
||
```
|
||
|
||
By default, `ctx.issues.createComment` attributes the comment to the calling
|
||
plugin's own agent (`authorAgentId`). A plugin that relays a message a human
|
||
actually sent — a chat gateway bridging Slack/Telegram replies back onto an
|
||
issue, for example — can instead attribute the comment to that person by
|
||
passing `actorUserId`:
|
||
|
||
```ts
|
||
await ctx.issues.createComment(issueId, replyText, companyId, {
|
||
actorUserId: verifiedSlackUser.paperclipUserId,
|
||
});
|
||
```
|
||
|
||
This requires the `issue.comments.create_human_attributed` capability in
|
||
addition to `issue.comments.create`. The host independently verifies that
|
||
`actorUserId` is an active human member of the issue's company before
|
||
applying the comment — a plugin cannot forge attribution to an arbitrary or
|
||
inactive user id. When the issue has a non-terminal status and an assigned
|
||
agent, a human-attributed comment also wakes that assignee, the same way a
|
||
board user's comment does in the web app.
|
||
|
||
Required capabilities:
|
||
|
||
| API | Capability |
|
||
|-----|------------|
|
||
| `ctx.issues.relations.get` | `issue.relations.read` |
|
||
| `ctx.issues.relations.setBlockedBy` / `addBlockers` / `removeBlockers` | `issue.relations.write` |
|
||
| `ctx.issues.getSubtree` | `issue.subtree.read` |
|
||
| `ctx.issues.assertCheckoutOwner` | `issues.checkout` |
|
||
| `ctx.issues.createComment` | `issue.comments.create` |
|
||
| `ctx.issues.createComment` with `actorUserId` | `issue.comments.create` + `issue.comments.create_human_attributed` |
|
||
| `ctx.issues.requestWakeup` / `requestWakeups` | `issues.wakeup` |
|
||
| `ctx.issues.summaries.getOrchestration` | `issues.orchestration.read` |
|
||
|
||
Plugin-originated mutations are logged with `actorType: "plugin"` and details fields `sourcePluginId`, `sourcePluginKey`, `initiatingActorType`, `initiatingActorId`, and `initiatingRunId` when a user or agent run initiated the plugin work.
|
||
|
||
## UI quick start
|
||
|
||
```tsx
|
||
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function DashboardWidget() {
|
||
const { data } = usePluginData<{ status: string }>("health");
|
||
const ping = usePluginAction("ping");
|
||
return (
|
||
<div style={{ display: "grid", gap: 8 }}>
|
||
<strong>Health</strong>
|
||
<div>{data?.status ?? "unknown"}</div>
|
||
<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 }`.
|
||
|
||
```tsx
|
||
import { usePluginData } 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 <div>Loading…</div>;
|
||
if (error) return <div>Error: {error.message}</div>;
|
||
|
||
return (
|
||
<div>
|
||
<p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
|
||
<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.
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function ResyncButton({ context }: PluginWidgetProps) {
|
||
const resync = usePluginAction("resync");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<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.
|
||
|
||
```tsx
|
||
import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function IssueLinearLink({ context }: PluginDetailTabProps) {
|
||
const { companyId, entityId, entityType } = context;
|
||
const { data } = usePluginData<{ url: string }>("linear-link", {
|
||
companyId,
|
||
issueId: entityId,
|
||
});
|
||
|
||
if (!data?.url) return <p>No linked Linear issue.</p>;
|
||
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
|
||
}
|
||
```
|
||
|
||
#### `useHostNavigation()`
|
||
|
||
Routes Paperclip-internal plugin links through the host router without a full document reload. Use `linkProps()` for anchors so the browser still gets a real `href` for copy-link, modifier-click, middle-click, and open-in-new-tab behavior.
|
||
|
||
```tsx
|
||
import { useHostNavigation } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function WikiSidebarLink() {
|
||
const hostNavigation = useHostNavigation();
|
||
return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
|
||
}
|
||
```
|
||
|
||
`linkProps("/wiki")` resolves against the active company prefix, so in company `PAP` it renders `href="/PAP/wiki"`. Already-prefixed paths such as `/PAP/wiki` are not prefixed again. For button-style commands, call `hostNavigation.navigate("/issues/PAP-123")`.
|
||
|
||
Avoid raw same-origin `href`s or `window.location.assign()` for Paperclip-internal navigation from plugin UI. Those bypass the host router and can reload the whole app. External links should keep normal anchors with `target="_blank"` and `rel="noopener noreferrer"` as appropriate.
|
||
|
||
#### `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 }`.
|
||
|
||
```tsx
|
||
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.
|
||
|
||
### UI authoring note
|
||
|
||
The host provides selected shared UI components through `@paperclipai/plugin-sdk/ui`.
|
||
Plugins can also use normal React components, their own CSS, or small design
|
||
primitives inside the plugin package.
|
||
|
||
Use `copyTextToClipboard` for every plugin copy action. The host selects the
|
||
modern Clipboard API in secure contexts and a compatible fallback in plain-HTTP
|
||
deployments.
|
||
|
||
```tsx
|
||
import { copyTextToClipboard } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
await copyTextToClipboard("text to copy");
|
||
```
|
||
|
||
Use the shared components when the plugin needs to look and behave like a native
|
||
Paperclip surface:
|
||
|
||
| Component | Use when |
|
||
|---|---|
|
||
| `MarkdownBlock` | Rendering markdown from plugin or host data |
|
||
| `MarkdownEditor` | Editing markdown with the host editor treatment |
|
||
| `FileTree` | Showing serializable workspace/wiki/import paths |
|
||
| `IssuesList` | Embedding a company-scoped native issue list |
|
||
| `AssigneePicker` | Selecting an agent or board user with the same picker as the new issue pane |
|
||
| `ProjectPicker` | Selecting a project with the same picker as the new issue pane |
|
||
| `ManagedRoutinesList` | Showing plugin-managed routines in settings UI |
|
||
|
||
#### Shared Markdown Components
|
||
|
||
Plugin UI can render markdown and edit markdown using the same host components
|
||
used by Paperclip issue comments and documents:
|
||
|
||
```tsx
|
||
import { MarkdownBlock, MarkdownEditor } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function WikiPageEditor() {
|
||
const [body, setBody] = useState("# Wiki page");
|
||
|
||
return (
|
||
<>
|
||
<MarkdownBlock content={body} />
|
||
<MarkdownEditor value={body} onChange={setBody} bordered />
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
`MarkdownBlock` can opt into Obsidian-style wikilinks when a plugin owns the
|
||
target URL shape:
|
||
|
||
```tsx
|
||
<MarkdownBlock
|
||
content={"See [[wiki/entities/paperclip|Paperclip]]."}
|
||
enableWikiLinks
|
||
wikiLinkRoot="/wiki/page"
|
||
/>
|
||
```
|
||
|
||
#### Shared FileTree
|
||
|
||
Plugin UI can render the host file tree without importing host internals:
|
||
|
||
```tsx
|
||
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 WikiFiles() {
|
||
return (
|
||
<FileTree
|
||
nodes={nodes}
|
||
expandedPaths={["wiki"]}
|
||
selectedFile="wiki/index.md"
|
||
onToggleDir={(path) => console.log("toggle", path)}
|
||
onSelectFile={(path) => console.log("select", path)}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### Shared Assignee and Project Pickers
|
||
|
||
Use `AssigneePicker` and `ProjectPicker` when a plugin needs to create, filter,
|
||
or configure work against Paperclip entities. Both are controlled components and
|
||
load their options from the host for the provided company.
|
||
|
||
```tsx
|
||
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function AssignmentControls({ companyId }: { companyId: string }) {
|
||
const [assignee, setAssignee] = useState("");
|
||
const [projectId, setProjectId] = useState("");
|
||
|
||
return (
|
||
<>
|
||
<AssigneePicker
|
||
companyId={companyId}
|
||
value={assignee}
|
||
onChange={(value, selection) => {
|
||
setAssignee(value);
|
||
console.log(selection.assigneeAgentId, selection.assigneeUserId);
|
||
}}
|
||
/>
|
||
<ProjectPicker
|
||
companyId={companyId}
|
||
value={projectId}
|
||
onChange={setProjectId}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 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` | — |
|
||
| `routeSidebar` | `PluginRouteSidebarProps` | — |
|
||
| `settingsPage` | `PluginSettingsPageProps` | — |
|
||
| `dashboardWidget` | `PluginWidgetProps` | — |
|
||
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
|
||
| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
|
||
| `toolbarButton` | `PluginToolbarButtonProps` | `entityId: string`, `entityType: string` |
|
||
| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||
| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||
| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
|
||
|
||
Example detail tab with entity context:
|
||
|
||
```tsx
|
||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||
import { usePluginData } 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 <div>Loading…</div>;
|
||
if (!data) return <p>No metrics available.</p>;
|
||
|
||
return (
|
||
<dl>
|
||
{Object.entries(data).map(([label, value]) => (
|
||
<div key={label}>
|
||
<dt>{label}</dt>
|
||
<dd>{value}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
);
|
||
}
|
||
```
|
||
|
||
## 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`, `globalToolbarButton`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
|
||
|
||
Declarative launcher example:
|
||
|
||
```json
|
||
{
|
||
"ui": {
|
||
"launchers": [
|
||
{
|
||
"id": "sync-project",
|
||
"displayName": "Sync",
|
||
"placementZone": "toolbarButton",
|
||
"entityTypes": ["project"],
|
||
"action": {
|
||
"type": "openDrawer",
|
||
"target": "sync-project"
|
||
},
|
||
"render": {
|
||
"environment": "hostOverlay",
|
||
"bounds": "wide"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.
|
||
|
||
When a launcher opens a host-owned overlay or page, `useHostContext()`,
|
||
`usePluginData()`, and `usePluginAction()` receive the current
|
||
`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.
|
||
full-page layouts without adding custom route parsing in the plugin.
|
||
|
||
## Project sidebar item
|
||
|
||
Plugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in `context.entityId`. Declare the slot and capability in your manifest:
|
||
|
||
```json
|
||
{
|
||
"ui": {
|
||
"slots": [
|
||
{
|
||
"type": "projectSidebarItem",
|
||
"id": "files",
|
||
"displayName": "Files",
|
||
"exportName": "FilesLink",
|
||
"entityTypes": ["project"]
|
||
}
|
||
]
|
||
},
|
||
"capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
|
||
}
|
||
```
|
||
|
||
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
|
||
|
||
```tsx
|
||
import {
|
||
useHostNavigation,
|
||
type PluginProjectSidebarItemProps,
|
||
} from "@paperclipai/plugin-sdk/ui";
|
||
|
||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||
const hostNavigation = useHostNavigation();
|
||
const projectId = context.entityId;
|
||
const projectRef = projectId; // or resolve from host; entityId is project id
|
||
return (
|
||
<a {...hostNavigation.linkProps(`/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
|
||
|
||
Two toolbar slot types are available depending on where the button should appear:
|
||
|
||
- **`globalToolbarButton`** — renders in the top bar on every page, scoped to the company. No entity context. Use for workspace-wide actions.
|
||
- **`toolbarButton`** — renders on entity detail pages (project, issue, etc.). Receives `entityId` and `entityType`. Declare `entityTypes` to control which pages the button appears on.
|
||
|
||
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.
|
||
|
||
Project-scoped example (appears only on project detail pages):
|
||
|
||
```json
|
||
{
|
||
"ui": {
|
||
"slots": [
|
||
{
|
||
"type": "toolbarButton",
|
||
"id": "sync-toolbar-button",
|
||
"displayName": "Sync",
|
||
"exportName": "SyncToolbarButton",
|
||
"entityTypes": ["project"]
|
||
}
|
||
]
|
||
},
|
||
"capabilities": ["ui.action.register"]
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import {
|
||
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 (
|
||
<>
|
||
<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 ? "Running…" : "Run sync"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
|
||
|
||
## Real-time streaming (`ctx.streams`)
|
||
|
||
Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.
|
||
|
||
### Worker side
|
||
|
||
In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
|
||
|
||
```ts
|
||
const plugin = definePlugin({
|
||
async setup(ctx) {
|
||
ctx.actions.register("chat", async (params) => {
|
||
const companyId = params.companyId as string;
|
||
ctx.streams.open("chat-stream", companyId);
|
||
|
||
for await (const token of streamFromLLM(params.prompt as string)) {
|
||
ctx.streams.emit("chat-stream", { text: token });
|
||
}
|
||
|
||
ctx.streams.close("chat-stream");
|
||
return { ok: true };
|
||
});
|
||
},
|
||
});
|
||
```
|
||
|
||
**API:**
|
||
|
||
| Method | Description |
|
||
|--------|-------------|
|
||
| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
|
||
| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
|
||
| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
|
||
|
||
Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
|
||
|
||
### UI side
|
||
|
||
Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
|
||
|
||
### Host-side architecture
|
||
|
||
The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
|
||
|
||
1. Worker emits `streams.emit` notification via stdout
|
||
2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
|
||
3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
|
||
|
||
The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
|
||
|
||
### Streaming agent responses to the UI
|
||
|
||
`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
|
||
|
||
```
|
||
UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
|
||
UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
|
||
```
|
||
|
||
The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
|
||
|
||
**Worker:**
|
||
|
||
```ts
|
||
ctx.actions.register("ask-agent", async (params) => {
|
||
const { agentId, companyId, prompt } = params as {
|
||
agentId: string; companyId: string; prompt: string;
|
||
};
|
||
|
||
const channel = `agent:${agentId}`;
|
||
ctx.streams.open(channel, companyId);
|
||
|
||
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||
|
||
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||
prompt,
|
||
onEvent: (event) => {
|
||
ctx.streams.emit(channel, {
|
||
type: event.eventType, // "chunk" | "done" | "error"
|
||
text: event.message ?? "",
|
||
});
|
||
},
|
||
});
|
||
|
||
ctx.streams.close(channel);
|
||
return { sessionId: session.sessionId };
|
||
});
|
||
```
|
||
|
||
**UI:**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||
|
||
interface AgentEvent {
|
||
type: "chunk" | "done" | "error";
|
||
text: string;
|
||
}
|
||
|
||
export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
|
||
const askAgent = usePluginAction("ask-agent");
|
||
const { events, connected, close } = usePluginStream<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:
|
||
|
||
```ts
|
||
// Create a session
|
||
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||
|
||
// Send a message and stream the response
|
||
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||
prompt: "Help me triage this issue",
|
||
onEvent: (event) => {
|
||
if (event.eventType === "chunk") console.log(event.message);
|
||
if (event.eventType === "done") console.log("Stream complete");
|
||
},
|
||
});
|
||
|
||
// List active sessions
|
||
const sessions = await ctx.agents.sessions.list(agentId, companyId);
|
||
|
||
// Close when done
|
||
await ctx.agents.sessions.close(session.sessionId, companyId);
|
||
```
|
||
|
||
Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
|
||
|
||
Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
|
||
|
||
## Testing utilities
|
||
|
||
```ts
|
||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||
import plugin from "../src/worker.js";
|
||
import manifest from "../src/manifest.js";
|
||
|
||
const harness = createTestHarness({ manifest });
|
||
await plugin.definition.setup(harness.ctx);
|
||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||
```
|
||
|
||
## Bundler presets
|
||
|
||
```ts
|
||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||
|
||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||
// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
|
||
// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
|
||
```
|
||
|
||
## Local dev server (hot-reload events)
|
||
|
||
```bash
|
||
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
|
||
```
|
||
|
||
Or programmatically:
|
||
|
||
```ts
|
||
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
|
||
const server = await startPluginDevServer({ rootDir: process.cwd() });
|
||
```
|
||
|
||
Dev server endpoints:
|
||
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
|
||
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
|
||
|
||
### Persistent application shell contributions
|
||
|
||
An `appShellOverlay` slot uses `ui.action.register` and the standard
|
||
`PluginWidgetProps` context. It survives navigation and unmounts on account or
|
||
company changes, sign-out and onboarding. Plugins own panel accessibility and
|
||
request cleanup. See [the distribution and lifecycle contract](../../../doc/plugins/DISTRIBUTION-PLUGINS.md).
|
||
|
||
## Project storage and task workspaces
|
||
|
||
A storage provider declares `workspaceProvider: { modes: ["isolated_workspace"],
|
||
strategies: ["git_worktree"] }`, `workspaces.manage`, and
|
||
`resourceLifecycle: ["project"]` (with `events.subscribe`). The same plugin must
|
||
implement `onResourceLifecycle` and `onWorkspaceOperation`. Project lifecycle
|
||
creates and retains the storage; workspace operations manage task checkouts in
|
||
that storage. Execution remains a separate environment driver contract.
|
||
|
||
`project.executionWorkspacePolicy.storageProviderPluginId` holds a project's storage:
|
||
an installed plugin's UUID, or `null` or no value for Core/local storage. Core
|
||
applies the installed company-enabled default once, when it creates the project. A
|
||
provider claims that default by declaring, for example:
|
||
|
||
```ts
|
||
workspaceProvider: {
|
||
modes: ["isolated_workspace", "operator_branch"],
|
||
strategies: ["git_worktree"],
|
||
defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
|
||
}
|
||
```
|
||
|
||
The declared defaults must be supported by the provider. Installing a provider
|
||
does not change existing projects. A Core/local project can move to a provider, and
|
||
a plugin-backed project stays attached to that plugin: requests to change it fail,
|
||
and it never falls back to local storage. Core applies missing mode and strategy
|
||
settings without writing them. Explicit settings remain intact; incompatible
|
||
settings fail with a configuration error. If more than one enabled provider claims
|
||
the default, creating a project needs an explicit choice. Core sends a provider
|
||
project lifecycle events only for projects attached to it, and acknowledges the
|
||
others on its behalf. No company settings row means enabled, matching the normal
|
||
plugin contract.
|
||
|
||
New and imported tasks leave an unspecified project mode inherited. Provider-owned
|
||
workspaces retain explicit project and task strategies in shared and operator-branch
|
||
modes. If neither supplies a strategy, those modes use `project_primary`; an agent's
|
||
Git strategy remains a fallback only for isolated mode.
|
||
|
||
Project responses keep the stored `executionWorkspacePolicy` and expose the
|
||
separate `workspaceProvider` configuration, including its source, effective policy,
|
||
provider choices and configuration error. The project configuration UI supports
|
||
inheritance, explicit plugins and Core/local storage. Conflicts remain readable so
|
||
operators can resolve them through this flow.
|
||
|
||
Core reserves an execution-workspace row and binds the task before calling
|
||
`onWorkspaceOperation`. Source-free task directories do not prepare project storage. The resolved provider
|
||
choice, including Core/local, stays fixed through source resolution and preparation.
|
||
Existing workspaces, including local workspaces, retain their original owner. Changing defaults never migrates, resets or recreates them;
|
||
normal retirement and cleanup gates still apply on disable/uninstall.
|
||
|
||
The hook receives a company, project, source revision, execution-workspace ID,
|
||
lifecycle generation, deadline, and stable operation ID. Run and agent IDs are
|
||
optional context. A storage hook must work without an environment lease, a live
|
||
agent, or a filesystem mount on Core. It must reconcile ambiguous retries before
|
||
repeating side effects. Use the workspace ID and generation to fence provider
|
||
operations; the operation ID identifies a retry of the same request.
|
||
Fresh inspections get a new operation ID so provider result caches cannot reuse
|
||
an earlier checkout status. Prepare and removal retries retain their IDs.
|
||
|
||
- `prepare` creates or restores the checkout. It returns an opaque, durable
|
||
`WorkspaceProviderBinding`, a branch, and the starting commit. `branchMode:
|
||
"existing"` must use an existing logical branch and must never reset its history.
|
||
A provider may materialize a local ref from a confirmed existing remote branch. Retried preparation must keep
|
||
local work. Providers may reject modes or strategies they do not support.
|
||
- `inspect` reports tracked, untracked, and ignored/local-only changes, the
|
||
current commit, merge state, and whether remote storage can recover it. Use
|
||
`null` when a remote recovery or merge check is unknown.
|
||
- `remove` checks the expected commit again and confirms removal. Busy, failed,
|
||
unsafe, and uncertain removal must throw. Core requires a clean checkout and
|
||
affirmative remote recovery or merge evidence before requesting removal.
|
||
|
||
References contain the installed plugin ID, project/source/workspace IDs, source
|
||
revision, and opaque provider coordinates. They must not contain secrets. Core
|
||
stores no local `cwd` for managed rows. `/workspaces/<id>` is a logical path in
|
||
Core's existing run metadata; Core must not use it for filesystem access.
|
||
|
||
For the initial execution integration, select a task-capable environment driver
|
||
from the same plugin. Core validates declared modes and strategies before it
|
||
reserves a workspace. Core pins the binding on the environment lease and supplies
|
||
it as `PluginEnvironmentTaskParams.workspace`. The execution hook resolves the
|
||
provider reference to guest mounts and the actual working directory. Core rejects
|
||
an execution driver from a different plugin. Native execution also requires the
|
||
plugin Runner task bridge; Core refuses to fall back to a host Runner when that
|
||
bridge is unavailable. This does not require merging the
|
||
storage and execution interfaces.
|
||
|
||
Core's existing archive, reopen, and terminal cleanup fences also cover provider
|
||
workspaces. The plugin must prevent project storage retirement while its task
|
||
workspaces still contain local data. Repository source and storage owner changes
|
||
are blocked while live managed workspaces depend on them. An authorized storage
|
||
owner or source edit retires closed workspaces whose provider removal is confirmed;
|
||
retired records cannot reopen. Inspection reconciles a pending restoration with
|
||
its recorded request, advances its generation, and keeps the Core row closed.
|
||
Cleanup must then inspect that generation afresh; it cannot reuse an earlier
|
||
removal receipt. Direct removal refuses unresolved restoration intent.
|
||
Core commits storage intent and recovery results with cleanup notifications so
|
||
incremental terminal-workspace sweeps revisit changed records.
|
||
Pending deletion is replayed before inspection, including after a lost removal
|
||
result. File browsing and
|
||
other Core-host filesystem operations are unavailable for managed storage.
|
||
|
||
Use `createWorkspaceProviderHarness` from `@paperclipai/plugin-sdk/testing` to
|
||
validate hook inputs and outputs without allocating an execution environment.
|
||
|
||
|
||
### Private project instance credentials
|
||
|
||
Project lifecycle plugins can opt into `projects.instance-secrets` alongside
|
||
`events.subscribe` and `resourceLifecycle: ["project"]`. Automatic project
|
||
callbacks receive `event.instanceSecrets`; return an `instanceSecrets` patch
|
||
with an acknowledged result. Core stores the patch in the existing native
|
||
instance-secret system and acknowledges the event in one transaction. A null
|
||
value removes a key. Retry and deferred results do not store patches.
|
||
|
||
Each company/project/installed-plugin owns its credentials. Up to 16 lowercase
|
||
snake_case keys may change per result; nonempty string values are limited to
|
||
128 KiB each. Core rejects replies if configuration, project sources, lifecycle
|
||
state, or secret versions changed during the callback. A duplicate response to
|
||
an acknowledged event cannot overwrite credentials. Deleted projects allow
|
||
removal only; their secrets are also deleted by the native owner foreign key.
|
||
|
||
Trusted worker code can use
|
||
`ctx.projects.getInstanceSecrets(projectId, companyId)` during a company-scoped
|
||
invocation to read its credentials without an external service call. Archived
|
||
or missing projects cannot be read this way. Values never appear in ordinary
|
||
lifecycle inbox listings or the company secret catalog. Do not expose values
|
||
through plugin entities, state, data handlers, UI, or logs. Agent instance
|
||
secrets retain their existing lifecycle contract. Back up the tenant's existing
|
||
local secrets master key together with its database.
|
||
|
||
### Project Git credentials
|
||
|
||
Trusted workspace providers can declare `projects.git-credentials` and call
|
||
`ctx.projects.resolveGitCredential(projectId, workspaceId, companyId)` from the
|
||
matching host-issued project lifecycle or workspace operation. Other invocations
|
||
cannot obtain credentials through this method. The workspace must be a current
|
||
repository in that project and company; plugins cannot supply arbitrary URLs.
|
||
|
||
Background work uses a project writer's explicit consent to their own personal,
|
||
company-installed GitHub Connector grant. It does not retain task context. Core
|
||
checks the exact grant and its owner's active company membership on every use.
|
||
Foreground preparation uses the active run's identity and trust policy instead.
|
||
Public repositories return null and require no connection. Private repositories
|
||
without authorization wait before provisioning. Repository visibility comes from
|
||
the Connector catalog and retained project selections; legacy URL-only sources
|
||
use anonymous discovery when visibility is unknown. A revoked or invalid private
|
||
repository authorization fails closed. There is no fallback to another grant.
|
||
Project consent follows the connection's current repository access, including
|
||
sources added later, rather than a separate project repository approval list.
|
||
The consenting user must retain project read access, and background credential
|
||
export requires the project's existing trust policy to allow standard execution.
|
||
Archived projects keep credential access only for host-issued workspace inspection
|
||
and removal with existing consent; provisioning remains blocked.
|
||
|
||
The return value is transient raw credential material with the Connector's
|
||
existing permissions. This capability is for trusted execution infrastructure,
|
||
not agent-facing tools or UI. Never expose or persist it. See
|
||
[GitHub identity](../../../doc/execution-github-identity.md#project-authorization-for-background-repository-work).
|