## Thinking Path > - Paperclip preserves agent work across failed runs and server restarts. > - A remote workspace must have a durable save before its source can be released. > - Portable capture normally writes a Git mirror and a full archive inside the sandbox. > - A full sandbox can prevent capture before the provider's streaming download starts. > - Retrying that write cannot create the required free space. > - This change transfers a checked standalone source and seals its archive on the controller. > - The source remains retained unless the existing durable save completes. ## Linked Issues or Issue Description Related: #18644 reduced temporary capture space. #18172 added the provider's disk-free directory transfer. This repair connects portable capture to that transfer when the archive itself cannot be created. **What happened?** Workspace capture returns `remote_workspace_sandbox_disk_full` after provider work has finished. File recovery repeats the same sandbox write. **Expected behavior** Recover a stable standalone workspace without requiring another full archive on the source disk. Preserve staged changes, untracked work, confined links and the original source. **Steps to reproduce** 1. Create a standalone Git repository with a commit, staged changes and an untracked result. 2. Use the existing real-command transport fixture. Inject ENOSPC into the sandbox helper write or archive write. 3. Run portable capture. Before this change it rejects the helper failure. 4. With the change, restore the saved archive after removing the fixture source. Check the commit, staged content, untracked result and Git connectivity. ## What Changed - Attempt one controller-side capture after an exact sandbox disk-full failure. - Read a source inventory without installing a helper. Use the existing provider bulk transfer with confined links and runtime exclusions. - Compare the transferred inventory with source fingerprints before and after sealing. - Reject copied Git pointers, alternates and administrative symlinks. Parse config as data without includes; allow only portable keys before controller Git normalization. - Preserve socket exclusions. Retain the source and remove incomplete controller staging after failure. - Update execution semantics and focused regression coverage. No database migration or new execution authority. ## Verification - TDD red on base `2f35eb1080`: the new full-sandbox test failed with `remote_workspace_sandbox_disk_full` before production edits. - Green: 202 transport and archive tests passed before the additional Git configuration guards. At head `8541086cb5`, all 116 transport tests and the PostgreSQL durability acceptance pass. The final `e24e173ec9` exclusion guard passes those 116 cases plus its new Git-history regression across the full run and focused rerun. Environment: macOS arm64, Node 26.4.0; real Node/Git commands and filesystem transfer. - Review regressions were also red before their fixes: an internal Git config symlink bypassed the check, and `core.attributesFile` could select a controller path. Git administrative symlinks and nonportable config now fail before normalization. The focused safety selection passes 11 cases. - Cases cover helper and archive ENOSPC, commit/staged/untracked continuity after source removal, confined links, sockets, changed source, corrupt or extra copied files, unsafe Git metadata, retained diagnostic text and failed download. - Real PostgreSQL acceptance passes: normal workspace save recovers one sandbox ENOSPC, commits the encrypted snapshot, clears its writer, loses the original allocation, then independently reloads the saved result twice. The first acceptance attempt used an incorrect expected status (`available`); the documented state is `committed`. No production change was needed for that correction. - Review regressions for untracked Python/Node environments and excessive Git roots failed before their fixes. Dependency exclusions now match normal capture. Controller parsing starts only after whole-tree/repository budgets and layout checks; refspec/settings budgets and a shared two-minute verification/normalization deadline bound further work. The excessive-root test verifies zero Git subprocesses. - Real provider-boundary red/green: Daytona direct tar and capacity-fallback PTY transfers first dropped `._result.txt`. The new optional mapping contract preserves all user names and excludes only current transfer scratch. Kubernetes implements the same contract. Explicit caller exclusions and archive validation remain in force. - Standalone Linux qualification on Node 24.22.2/Debian bookworm: fresh shared/SDK build; Daytona and Kubernetes typecheck and build; Daytona file-sync/capacity/diagnostics 68/68 pass; Kubernetes file-sync 31/31 pass, including the actual `/proc` case. Qualification was refreshed again on the final `be6f7feacc` source tree: current shared/SDK rebuilt; all 815 staged source/config/manifest hashes match the delivered tree before and after testing. An initial container run as root bypassed a read-only file test; the same source passes as uid 1000. Installing zstd enabled eight initially skipped cases. - A further review regression proved `.git/pyvenv.cfg` could exclude Git history as a dependency. The inventory now handles `.git` before dependency filtering, matching normal capture. The full capture/restore test verifies the original commit and saved work. The first green attempt incorrectly compared command-result object identity; correcting the test to compare stdout passed with no production change. - Further red/green regressions cover read-only private-copy cleanup, a regular file replaced by a FIFO before open, case aliases of Git administrative metadata, and permission preservation under restrictive umask. Nonblocking no-follow file handles verify identity before and after reading. Cleanup changes only private controller directories. Provider extraction preserves ordinary permissions, refuses special mode bits before writing, and does not adopt source ownership, ACLs, attributes or flags. Final macOS Daytona tests pass 65/65; the final Linux qualification above was repeated on these exact provider sources. - Focused strict TypeScript compilation of the inventory and archive modules passes. - The first sandboxed archive run failed three Unix socket fixtures with EPERM. The permitted run outside that filesystem sandbox passed all tests. - Initial full local server typecheck could not resolve the unbuilt Runner package and produced cascading errors. Exact-head `e24e173ec9` CI typecheck, build, Runner and browser checks pass. That CI attempt failed two server shards: the unchanged retained-cleanup backlog test timed out at 15 seconds and passes locally unchanged; ten cache-reclaim fixture oracles failed because directory fallback consumed their sealed-archive counters/faults. Final commit `caed4e4f83` isolates the intended unavailable-directory boundary while preserving archive counts, recovery status, integrity and no-second-reclamation assertions. Those ten failures reproduced locally before the fixture edit. The full affected file passes 394/395; its sole other HTTP socket-hang-up case passes unchanged in an isolated rerun. This is combined coverage of all 395 cases, not a single complete green run. The final branch integrates master `53645a0104` at `98ea07225592930cbb0d18bcf250bc64f6bb13b4`, preserving upstream workspace phase timings. After integration: 117 transport cases, the PostgreSQL durability acceptance, the existing object-storage/timing regression, and 12 affected cache cases pass. The expanded recovery file now has 421 cases. Final-head `be6f7feacc45c1e32a26a2417e973f1e3752fd8d` CI passes all 55 applicable checks (three skipped, one neutral review), including the expanded recovery suite, typecheck, builds and Runner coverage. [CI result](https://github.com/paperclipai/paperclip/actions/runs/42251345554). - Provider exclusion regressions were red through four real Linux transfer paths: Daytona direct archive, direct safe-link repair, capacity PTY repair, and Kubernetes streaming. A root socket and nested regular file share a basename; literal glob-character files/subtrees also survive. Preserve mode now anchors tar exclusions as literal root-relative paths and escapes `find -path` pruning. Default callers keep glob semantics. Receipt-count regressions were also red before correcting preserved-output counting. All 31 Kubernetes and 68 Daytona tests, builds and typechecks pass on the final source with 815 matching inputs. - The final semantics documentation explicitly records special mode bits, the stricter config allowlist (including innocuous unlisted keys), and provider qualification limited to Daytona/Kubernetes; these address the optional current-head review observations without broadening controller Git authority. - No Daytona credential is available in this session. Provider transfer is a local filesystem boundary in these tests. Hosted task recovery and rollout remain unverified. ## Risks - Recovery reads the source repeatedly and transfers the standalone tree. It can take longer than normal capture. - Linked worktrees, initialized submodules, Git administrative symlinks, nonportable Git configuration, special mode bits and alternate object stores remain retained if the normal capture cannot finish. This fallback does not follow their pointers onto the controller. - A provider without usable directory export, a changed source, or insufficient controller space cannot produce a receipt. The original source stays retained. - The server, SDK and affected provider plugins must be built from this revision together. An older provider can fail inventory verification on names it omits; it cannot publish an incomplete save. No merge, publication or deployment is included. ## Model Used OpenAI GPT-6 family through Codex, with reasoning, code editing, shell tools and automated tests. The exact serving model ID and context window are not exposed in this session. ## 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 references) - [x] My branch name describes the change 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
@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 callssetup(ctx).entrypoints.ui(required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
Install
pnpm add @paperclipai/plugin-sdk
Current deployment caveats
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
- 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,
npmavailable at runtime, and network access to the package registry used for plugin installation. - Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
- 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.assetsis 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
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 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.
- Capability: Add
jobs.scheduletomanifest.capabilities. - Declare jobs in
manifest.jobs: each entry hasjobKey,displayName, optionaldescription, andschedule(a 5-field cron expression). - Register a handler in
setup()withctx.jobs.register(jobKey, async (job) => { ... }).
Cron format (5 fields: minute, hour, day-of-month, month, day-of-week):
| Field | Values | Example |
|---|---|---|
| minute | 0–59 | 0, */15 |
| hour | 0–23 | 2, * |
| day of month | 1–31 | 1, * |
| month | 1–12 | * |
| day of week | 0–6 (Sun=0) | *, 1-5 |
Examples: "0 * * * *" = every hour at minute 0; "*/5 * * * *" = every 5 minutes; "0 2 * * *" = daily at 2:00.
Job handler context (PluginJobContext):
| Field | Type | Description |
|---|---|---|
jobKey |
string | Matches the manifest declaration. |
runId |
string | UUID for this run. |
trigger |
"schedule" | "manual" | "retry" |
What caused this run. |
scheduledAt |
string | ISO 8601 time when the run was scheduled. |
Runs can be triggered by the schedule, manually from the UI/API, or as a retry (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.
Example:
Manifest — include jobs.schedule and declare the job:
// In your manifest (e.g. manifest.ts):
const manifest = {
// ...
capabilities: ["jobs.schedule", "plugin.state.write"],
jobs: [
{
jobKey: "heartbeat",
displayName: "Heartbeat",
description: "Runs every 5 minutes",
schedule: "*/5 * * * *",
},
],
// ...
};
Worker — register the handler in setup():
ctx.jobs.register("heartbeat", async (job) => {
ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
});
UI slots and launchers
Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in manifest.ui.slots with type, id, displayName, exportName; for context-sensitive slots add entityTypes. Declare launchers in manifest.ui.launchers (or legacy manifest.launchers).
Slot types / launcher placement zones
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:
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:
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.
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:
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:
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-foldersGET /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/statusPOST /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validatePUT /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:
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.
Expanded create/update fields include blockers, billing code, board or agent assignees, labels, namespaced plugin origins, request depth, and safe execution workspace fields:
const child = await ctx.issues.create({
companyId,
parentId: missionIssueId,
inheritExecutionWorkspaceFromIssueId: 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:
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:
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:
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:
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:
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:
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
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 }.
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.
import { useState } from "react";
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
export function ResyncButton({ context }: PluginWidgetProps) {
const resync = usePluginAction("resync");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setBusy(true);
setError(null);
try {
await resync({ companyId: context.companyId });
} catch (err) {
setError((err as PluginBridgeError).message);
} finally {
setBusy(false);
}
}
return (
<div>
<button onClick={handleClick} disabled={busy}>
{busy ? "Syncing..." : "Resync Now"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}
useHostContext()
Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
export function IssueLinearLink({ context }: PluginDetailTabProps) {
const { companyId, entityId, entityType } = context;
const { data } = usePluginData<{ url: string }>("linear-link", {
companyId,
issueId: entityId,
});
if (!data?.url) return <p>No linked Linear issue.</p>;
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
}
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.
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 hrefs 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 }.
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.
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:
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:
<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:
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.
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:
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:
{
"ui": {
"launchers": [
{
"id": "sync-project",
"displayName": "Sync",
"placementZone": "toolbarButton",
"entityTypes": ["project"],
"action": {
"type": "openDrawer",
"target": "sync-project"
},
"render": {
"environment": "hostOverlay",
"bounds": "wide"
}
}
]
}
}
The host returns launcher metadata from GET /api/plugins/ui-contributions alongside slot declarations.
When a launcher opens a host-owned overlay or page, useHostContext(),
usePluginData(), and usePluginAction() receive the current
renderEnvironment through the bridge. Use that to tailor compact modal UI vs.
full-page layouts without adding custom route parsing in the plugin.
Project sidebar item
Plugins can add a link under each project in the sidebar via the projectSidebarItem slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in context.entityId. Declare the slot and capability in your manifest:
{
"ui": {
"slots": [
{
"type": "projectSidebarItem",
"id": "files",
"displayName": "Files",
"exportName": "FilesLink",
"entityTypes": ["project"]
}
]
},
"capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
}
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
import {
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.). ReceivesentityIdandentityType. DeclareentityTypesto 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):
{
"ui": {
"slots": [
{
"type": "toolbarButton",
"id": "sync-toolbar-button",
"displayName": "Sync",
"exportName": "SyncToolbarButton",
"entityTypes": ["project"]
}
]
},
"capabilities": ["ui.action.register"]
}
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:
const plugin = definePlugin({
async setup(ctx) {
ctx.actions.register("chat", async (params) => {
const companyId = params.companyId as string;
ctx.streams.open("chat-stream", companyId);
for await (const token of streamFromLLM(params.prompt as string)) {
ctx.streams.emit("chat-stream", { text: token });
}
ctx.streams.close("chat-stream");
return { ok: true };
});
},
});
API:
| Method | Description |
|---|---|
ctx.streams.open(channel, companyId) |
Open a named stream channel and associate it with a company. Sends a streams.open notification to the host. |
ctx.streams.emit(channel, event) |
Push an event to the channel. The companyId is automatically resolved from the prior open() call. |
ctx.streams.close(channel) |
Close the channel and clear the company mapping. Sends a streams.close notification. |
Stream notifications are fire-and-forget JSON-RPC messages (no id field). They are sent via notifyHost() synchronously during handler execution.
UI side
Use the usePluginStream hook (see Hooks reference above) to subscribe to events from the UI.
Host-side architecture
The host maintains an in-memory PluginStreamBus that fans out worker notifications to connected SSE clients:
- Worker emits
streams.emitnotification via stdout - Host (
plugin-worker-manager) receives the notification and publishes toPluginStreamBus - SSE endpoint (
GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...) subscribes to the bus and writes events to the response
The bus is keyed by pluginId:channel:companyId, so multiple UI clients can subscribe to the same stream independently.
Streaming agent responses to the UI
ctx.streams and ctx.agents.sessions are complementary. The worker sits between them, relaying agent events to the browser in real time:
UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
Worker:
ctx.actions.register("ask-agent", async (params) => {
const { agentId, companyId, prompt } = params as {
agentId: string; companyId: string; prompt: string;
};
const channel = `agent:${agentId}`;
ctx.streams.open(channel, companyId);
const session = await ctx.agents.sessions.create(agentId, companyId);
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
prompt,
onEvent: (event) => {
ctx.streams.emit(channel, {
type: event.eventType, // "chunk" | "done" | "error"
text: event.message ?? "",
});
},
});
ctx.streams.close(channel);
return { sessionId: session.sessionId };
});
UI:
import { useState } from "react";
import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
interface AgentEvent {
type: "chunk" | "done" | "error";
text: string;
}
export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
const askAgent = usePluginAction("ask-agent");
const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });
const [prompt, setPrompt] = useState("");
async function send() {
setPrompt("");
await askAgent({ agentId, companyId, prompt });
}
return (
<div>
<div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button onClick={send}>Send</button>
{connected && <button onClick={close}>Stop</button>}
</div>
);
}
Agent sessions (two-way chat)
Plugins can hold multi-turn conversational sessions with agents:
// Create a session
const session = await ctx.agents.sessions.create(agentId, companyId);
// Send a message and stream the response
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
prompt: "Help me triage this issue",
onEvent: (event) => {
if (event.eventType === "chunk") console.log(event.message);
if (event.eventType === "done") console.log("Stream complete");
},
});
// List active sessions
const sessions = await ctx.agents.sessions.list(agentId, companyId);
// Close when done
await ctx.agents.sessions.close(session.sessionId, companyId);
Requires capabilities: agent.sessions.create, agent.sessions.list, agent.sessions.send, agent.sessions.close.
Exported types: AgentSession, AgentSessionEvent, AgentSessionSendResult, PluginAgentSessionsClient.
Testing utilities
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import plugin from "../src/worker.js";
import manifest from "../src/manifest.js";
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
Bundler presets
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
Local dev server (hot-reload events)
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
Or programmatically:
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
const server = await startPluginDevServer({ rootDir: process.cwd() });
Dev server endpoints:
GET /__paperclip__/healthreturns{ ok, rootDir, uiDir }GET /__paperclip__/eventsstreamsreloadSSE events on UI build changes
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.
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:
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.
preparecreates or restores the checkout. It returns an opaque, durableWorkspaceProviderBinding, 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.inspectreports tracked, untracked, and ignored/local-only changes, the current commit, merge state, and whether remote storage can recover it. Usenullwhen a remote recovery or merge check is unknown.removechecks 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.