Files
PaperClipAI/packages/plugins/sdk
DottaandPaperclip 45a3a92e6d fix(environments): persist sandbox ownership before creation (#18357)
## Thinking Path

> - Paperclip manages companies and the agents that do their work.
> - Remote execution creates provider resources that need a durable
owner.
> - The server could create a sandbox before it saved its lease.
> - A failed lease write could leave cleanup data only on the old web
server disk.
> - This change saves a company-scoped operation before creation and
transfers ownership atomically.
> - Recovery finds the original Daytona resource or records an explicit
manual reconciliation outcome.

## Linked Issues or Issue Description

**What happened?**
A sandbox could exist without a lease after a database failure or
company rebind. Failed cleanup recording could then create a local
fallback file. Replacing that web server could lose the cleanup
reference.

**Expected behavior**
A failed admission write makes no provider call. An interrupted creation
retains a committed operation. Recovery must not create a second sandbox
or destroy an attached lease.

**Steps to reproduce**
1. Run the disposable PostgreSQL cases in
`server/src/__tests__/sandbox-creation-operations.test.ts`.
2. Reject the lease insert after the controlled provider creates its
resource.
3. Start a new receiver process with a different empty web disk. It must
find the same resource and create one cleanup owner.

Related ownership report: Refs #14672. This change addresses new
allocations before lease persistence. It does not replace existing lease
recovery.

## What Changed

- Add an idempotent migration and a small creation-operation table.
Retain original company, environment and run identities across deletion.
- Commit creation intent before run, ad-hoc lease, and plugin task
replacement calls. Fence duplicate requests and ambiguous lease commits.
- Retain a known returned task resource ID in the existing operation
before attachment. Preserve manual reconciliation and never delete an
ambiguously attached resource.
- Keep company and run authority checks and atomic lease transfer with
the lease owner.
- Reuse Daytona names, labels, read-only inspection and claimed cleanup.
Keep provider payload handling in the provider.
- Retain nonsecret credential identity hashes. Refuse recovery lookup
under a changed credential value.
- Record manual reconciliation for missing/deleted or explicitly
inactive saved credentials, versions, and provider vaults. Keep
temporary resolution failures retryable. Never reactivate a disabled
credential.
- Use transaction signals and concrete deadlines. Failed records back
off independently. Manual reconciliation stays durable and quiet.
- Remove new orphan-file fallback writes from the covered paths. Keep
legacy file replay and its tests for cutover.
- Replace obsolete fallback-producer assertions with normal-entrypoint
durability tests. Keep cleanup, diagnostics and warm handoff coverage.

## Verification

- [Independent current-head
verification](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6534744320)
passed 190/190 combined tests, 40/40 incoming TestOps tests, full
workspace typecheck, and build. PR head
`958af986130f25e136065ad30d1fdd843c46255c` has tree
`e6c3e5b02182d5941a70ccf5bf55638df3c2e28d`, identical to tested combined
commit `b9d7a9c2e9b30f0153685fb24a5f70b56132201f`. Real PostgreSQL,
compiled Rust Runner and controlled object providers were used;
external-S3 integration remains outside this local proof.
- Current head `958af986130f25e136065ad30d1fdd843c46255c` normally
merges master `8595efc20bda3ce20a9fbbf34a5d156a6fcf6d57`. The sole
conflict was the maintenance test: a parameterized case now preserves
both legacy-local `local_storage` and valid shared-S3
`provider_mismatch` checks, including the parent local receipt, exact
version identity, missing-snapshot/no-seed refusal, and unchanged
persisted row. The three master TestOps files remain intact. No
production, schema, migration, or package changes were made after the
fully qualified prior head `0b4d05505e`. Independent review approved the
resolution; the full managed-skill-content file and Cloud guard controls
pass 58/58 (105.11 seconds). [Required PR CI passed on this exact merge
head, first
attempt](https://github.com/paperclipai/paperclip/actions/runs/41136987412):
54 successful checks and three intended skips. The additional Paperclip
review is neutral at 4/5; two external Superagent checks (Contributor
trust and Security Scan) remain in progress with zero annotations and no
assessment, so they are not passing scan evidence. Greptile reviewed
exact head `958af986130f25e136065ad30d1fdd843c46255c` at 5/5; the final
thread audit has zero unresolved threads. All completed evidence below
retains its recorded revision scope. The additional Paperclip Review on
`958af98613` is neutral at 4/5 with a Ship verdict and no must-fix or
should-fix item. Its optional observation is that a generic create
rejection remains pending/manual even when an operator believes
allocation did not occur. This change preserves uncertainty because the
generic create-error contract does not prove provider absence; no new
error classification or provider-specific compensation was added.

- [Independent final-source
verification](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6534328247)
passed all 189 real-PostgreSQL/Runner and controlled-fixture cases, full
workspace typecheck, and build. The integration commit
`c8411a8528386f4824b340ec5fdaaed1efb9ea69` has the same source tree as
final PR head `0b4d05505e` (tree
`bda65efc14b4c904d0f589154db77bb160641c8c`). Controlled object-provider
coverage passed; the separately guarded external-S3 suite was not run
because the bounded local MinIO pull was unavailable. This includes the
two final compatibility fixes.
- The first final-head CI attempt exposed one E2E timing failure in
`disconnect-write-recovery.spec.ts:181`: the title stayed unchanged
through the 15-second session-recovery poll. The same original case
passed in 13.8 seconds on immediately prior head `7ec06f77b7`, and all
UI/session/E2E source is identical across those heads. The failure log
and CI artifacts are retained; no cause is claimed. One failed-job-only
retry is authorized on this evidence, without changing a timeout,
assertion, or production behavior. [The single failed-job retry
completed
successfully](https://github.com/paperclipai/paperclip/actions/runs/41133619710/attempts/2):
all 35 E2E shard cases passed (6.6 minutes), including the original
session-recovery case in 14.0 seconds. The initial failure remains
disclosed and its cause remains unproven.
- Final head `0b4d05505ee0aa72f2981cdff74761406960ec73` adds two
independently reviewed integration fixes after the complete 7ec CI
matrix exposed two deterministic failures. Server 9/12 failed legacy
local-storage maintenance because the CLI reached the new Cloud server
startup guard; server 5/12 found the Daytona package version did not
match its bumped manifest. The failed run is retained at
https://github.com/paperclipai/paperclip/actions/runs/41131204203. It is
not passing evidence and was not blindly retried.
- Maintenance now constructs the existing explicit storage dependency
after loading the required persisted Cloud snapshot, and passes it to
both maintenance owners. It can verify legacy local storage before
provider cutover while normal Cloud server startup remains guarded. The
original case reproduced red locally, then the complete
managed-skill-content and managed-cloud-prerequisites files passed 57/57
(32.05 seconds), including missing-snapshot refusal, unchanged stored
configuration, and existing server guard controls. Daytona package
version now matches manifest 0.1.10; the complete bundled package
identity file passed 14/14 (3.98 seconds), and explicit
manifest/lifecycle controls passed 2/2 (328 intentionally filtered; 3.42
seconds). An initial workspace-filter command matched no project and is
not counted as pass evidence. The final diff changes no sandbox runtime
or schema behavior. [Full CI is green on this exact final
head](https://github.com/paperclipai/paperclip/actions/runs/41133619710/attempts/2):
56 successful checks, three intended skips, one additional
incomplete/neutral Paperclip Review, and no pending or failed checks.
The neutral review is not a passing assessment. [Greptile reviewed the
exact final head at
5/5](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6529396969),
with no new actionable issues and all review threads resolved.
- Final combined head `7ec06f77b7c14a745c569d18401f16df3d26b626`
includes master managed-skills migration 0417 and shared-storage
prerequisites from `198218bf55` through normal merges. Sandbox creation
moves to generated `0418_curved_lady_mastermind.sql`; its idempotent SQL
is byte-for-byte unchanged. The regenerated 0418 snapshot contains both
sandbox creation operations and the master skill mutation receipts, and
points to the master 0417 snapshot. Master SQL and journal history
remain intact. Exactly five snapshots remain, 0414 through 0418. The
only sandbox-specific changes since the approved credential policy are
migration metadata/number and the matching test/document references; the
sandbox runtime files are unchanged.
- [Independent final combined
verification](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6533960026)
passed on integration revision `e6f05b4cf0cd`, containing final sandbox
head `7ec06f77b7` and actual master `198218bf55`: migration/declaration
checks, all 158 real-PostgreSQL/Runner and controlled-fixture cases (251
seconds), full workspace typecheck (81 seconds), and build (35 seconds).
- Final combined migration proof: fresh real PostgreSQL has both tables.
A separate original-0417 sandbox table with a retained pending row
survives two applications of the renamed 0418 SQL unchanged, with the
expected primary/index and foreign-key structure. Migration
numbering/safety checks pass. The normal runtime migration reapply case
passes 1/1 (20 intentionally filtered cases; 46.69 seconds). The first
temporary qualification harness stopped before any database assertion
because its external `.ts` file selected CommonJS; the `.mts` harness
corrected that setup and passed. Independent review approved the exact
metadata and reference diff. Earlier full suite and live-provider
results below retain their stated revision scope.

- Credential-policy head `c606d473b03709f27577a55a1dfc0b0d3b4359fd`
handles the remaining inactive-credential review warning. The existing
boundary accepts only concrete secret-owner 422 codes and exact
no-details disabled/coming-soon vault messages. Unknown 422,
authorization errors, provider wrappers, and temporary database/provider
failures still propagate. Independent source review approved this
three-file correction. No generic resolver, schema, provider protocol,
retry cap, or activation behavior changed.
- Creation/cleanup qualification at `c606d473b0`: all 39 cases pass
against real PostgreSQL (81.27 seconds). The new cases use actual
disabled/archived saved secrets, a valid disabled version, and provider
vaults created ready before normal API updates disable them or mark them
coming soon. Each remains manual through restart/repeated scans, with
zero provider lookup and no retry deadline. The independent plugin idle
blocker remains intact. The temporary 503 control retains retry
ownership and later recovers one cleanup owner. Before this correction,
all five inactive-state cases remained pending; that failed evidence is
retained. The prior 18-case known-ID task proof below belongs to
`237144fcf6`, whose helper and task path are unchanged by this
correction.

- Known-ID head `237144fcf69a249c0e0310563428c0f55ed6d55c` also
addresses the independent task-replacement reliability warning. The
existing creation operation now saves only the known nonsecret returned
resource ID before expiry validation and attachment. The write is scoped
to operation/company/original environment and allows only a missing or
matching ID. It changes no ownership state and copies no resolved
configuration, credentials, or provider payload. Independent source
review approved this four-file fix.
- The complete real-PostgreSQL task suite passes 18/18 (9.52 seconds).
Rejected attachments retain the ID through manual reconciliation and
repeated scans. A real committed attachment followed by an injected lost
response retains the same active lease and attached operation, with no
release or second create. Same-ID retries work; conflicting IDs and
foreign companies cannot change it. The initial three regression cases
failed because the ID was absent, before the fix.
- The shipped Daytona manifest does not declare task support, so it
cannot enter this generic replacement path. The dispatcher requires both
declared task support and an environmentTask worker method. Generic
drivers without creation lookup still use manual reconciliation, with a
known returned ID retained when the database write can commit. No
automatic lookup capability or unconditional compensation was added.

- Previous boundary-fix head `8ba77def151ea85eeae2db7693254db7f87673b0`
fixes the fresh review finding about missing credentials. Only known
secret-owner 404 results at the recovery secret-resolution boundary
select the existing manual outcome. Provider inspection and transient
database/provider errors remain retryable. Independent source review
approved this change.
- Secret-boundary focused acceptance at `8ba77def15`: all 34 cases pass
against isolated real PostgreSQL. The complete creation suite passes
16/16 (100.75 seconds); the complete cleanup coordinator suite passes
18/18 (99.83 seconds). The new cases use normal saved-secret creation
and deletion, confirm zero provider lookup, restart/repeated scans, no
automatic deadline, preserved independent plugin idle blocking, and no
additional idle blocker after the controlled worker supplies its quiet
proof. A temporary 503 resolution failure preserves pending ownership
and later recovers one cleanup owner. No additional live provider
resource was created.
- Failed attempts remain part of the evidence. The initial red run
reproduced the pending-operation bug and also exposed a numeric-deadline
test harness mistake, which was corrected. A later combined run reported
an existing replacement-receiver failure and a new idle-fixture
assertion failure; it was interrupted while the next test file remained
in imports. An isolated diagnostic confirmed that the manual state was
correct but the installed fixture plugin correctly blocked sleep without
its independent quiet proof. The corrected test retains that blocker
assertion before supplying the controlled worker witness. The unchanged
replacement-receiver case passes in the final complete 16-case run; its
earlier failure is not counted as a pass or assigned a proven cause. No
timeout or production idle guard was relaxed.
- Greptile reviewed previous combined head
`7ec06f77b7c14a745c569d18401f16df3d26b626` at 5/5, with no unresolved
review threads. Its CI produced the two concrete integration failures
addressed above. The earlier CI and live evidence below remain
historical evidence, not a claim that the current full gates have
completed.

- Master integration update: merged `dbff3747b8` and then latest master
`76fde891cc` without rewriting branch history. Only `doc/DATABASE.md`
conflicted; the sandbox ownership, object-backed run evidence, and cloud
configuration sections all remain. The master merges changed no sandbox
implementation, migration, or provider code. Migration numbering/safety
checks pass and exactly five schema snapshots remain. At merge head
`f5d23d4a59828835d01c150f83fa2645fb2d3bec`, the two focused
creation/coordinator suites pass all 32 cases against isolated real
PostgreSQL (64.57 seconds). Those merge-head checks precede the
missing-credential fix below. The earlier live Daytona proof still
covers the unchanged normal creation/lookup/teardown path; the new
missing-credential branch is qualified with real PostgreSQL and
controlled provider hooks.

The optional Superagent Security Scan was inconclusive on prior head
`7ec06f77b7`: its pinned PR merge-base checkout failed or timed out,
with a neutral conclusion and zero annotations. An earlier scan could
not start within 120 seconds; a bounded rerun request returned HTTP 404.
These scanner failures are not passing security-scan evidence. On final
head `0b4d05505e`, the additional Paperclip Review check is neutral: its
assigned review execution did not complete and produced no validated
assessment. Fresh Greptile 5/5 and independent source review are
separate evidence. Previous-head required CI, independent review, and
the live Daytona qualification below passed.

- [Complete previous-head
CI](https://github.com/paperclipai/paperclip/actions/runs/41105726052)
passed at `0b5cbb763ab8faa25aa0ff1d8a405cdc0d3db409`: 53 successful
checks and three intended skips, including typecheck, full tests, build
and E2E. Greptile is 5/5 on this head and both original findings are
resolved.

- Initial head: 200 PostgreSQL runtime, creation and coordinator tests
passed. The corrected head also passes all 32 creation and coordinator
tests, including deletion before/during lookup, a failed lookup after
deletion, restart, settled polling and actual idle-safety checks. Cases
cover failed admission, failed lease writes, concurrent recovery, lost
provider replies, ambiguous transfer retries, company rebind,
environment deletion, stopped runs, lookup backoff, restart, drain and
settled SQL quiet.
- Separate receiver processes: passed. A controlled HTTP provider
retains the resource while three independent receiver processes use two
empty web disks. Recovery creates one cleanup owner and never repeats
creation.
- Fresh PostgreSQL migration and two additional applications: passed.
The pending creation row survives unchanged.
- Server TypeScript and SDK/shared build checks pass after the
corrections. Migration numbering/safety checks passed. Exactly five
schema snapshots remain.
- Daytona adapter: all 330 tests passed, including stable-name lookup,
ownership checks, deletion and the updated manifest version. An earlier
run exposed the version expectation and it was corrected. Previous-head
CI passed.
- Live Daytona acceptance passed on `0b5cbb763a`: isolated real
PostgreSQL, normal runtime acquisition through independent receiver
processes, actual Daytona plugin/SDK 0.203.0, one disposable Ubuntu
24.04 sandbox (1 CPU / 1 GiB RAM / 1 GiB disk). A forced lease insert
failure retained the committed operation; a fresh receiver with empty
disk found the same sandbox, repeated recovery retained one cleanup
owner, and the actual claimed cleanup sweep expired the lease and
resolved the operation. Independent SDK lookup returned typed not-found;
ordinary recorded-ID inspection returned destroyed. This successful
attempt made one create, one recovery lookup, and one teardown. Two
earlier failed harness/provider-confirmation attempts are disclosed in
the [live acceptance
record](https://github.com/paperclipai/paperclip/pull/18357#issuecomment-6530313054).
The coordinator independently queried all three exact resource IDs
through the SDK and confirmed typed not-found for each.
- Complete CI-affected heartbeat/plugin warm-task suites passed 21/21
after test corrections. An unchanged workspace exposure case passed in
isolation after the prior CI shard reported an unhandled readiness
rejection. Complete previous-head CI and refreshed review passed.

## Risks

- Deleted companies retain an explicit manual reconciliation outcome.
Recovery does not retry missing credentials or insert a lease for a
deleted company.
- Only Daytona declares automatic creation lookup. Other uncertain
providers retain `needs_reconciliation`. A missing creation name alone
cannot prove that a late allocation will never appear.
- Pending operations use the existing process-local signal and startup
recovery model. This does not add cross-replica wake delivery or prove
deployed sleep.
- Legacy direct provider probes and custom-image setup use separate
lifecycles. They are outside this replacement of the run-lease
orphan-file fallback. The normal Test path that acquires a runtime lease
is covered.
- Existing orphan files still require the separate cutover inventory and
settlement. This change does not delete them.
- The schema change is additive. The new SQL can be reapplied.
Historical SQL and journal entries are preserved; only the oldest
generated snapshot is pruned.

## Model Used

OpenAI `gpt-6.1-sol`, with tool use, code execution and repository
review. The harness did not report the context-window size.

## 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


The required branch status checks (`ci / verify`, `ci / e2e`) and all
test/build jobs passed on this head. The non-required Superagent
Contributor trust and Security Scan checks are still running without
findings; they are not claimed as passes. No required check or branch
rule is overridden.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-30 17:38:23 -05:00
..

@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

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.

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.

  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:

// 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-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:

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.). 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):

{
  "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:

  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:

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__/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.

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.

Set project.executionWorkspacePolicy.storageProviderPluginId to an installed plugin's UUID for an explicit selection, or null for explicit Core/local storage. Omit the field to inherit the installed company-enabled default. 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. Core applies missing mode and strategy settings to Git projects, including existing unconfigured projects, without writing their policy. Explicit settings remain intact; incompatible settings fail with a configuration error. Multiple enabled default claims require an explicit project choice or disabling a conflicting provider. Worker health does not change the selected default: unavailable providers fail without local fallback. 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.