How to create your own Paperclip plugin

Build Issue Pulse: a dashboard widget that shows open work for the company you have selected in Paperclip.

You will create a manifest, a worker that reads issues, and a React widget with loading, empty and error states. Then you will test company isolation and install the plugin from a local folder. The example is deliberately read-only: it needs no external service, API key or database of its own.

Paperclip’s plugin runtime is in alpha. This guide follows the official source at b9750b1 (opens in a new tab), reviewed on 9 October 2026. The sample was typechecked, built and tested against that revision; it has not been installed in a running Paperclip instance. Use a matching host and SDK, and repeat the local checks below.

Choose a small first plugin

A plugin is a good fit when you need a Paperclip UI surface or a worker that responds to events. If you only want to teach an agent a workflow, start with a skill. A new model runtime belongs in an adapter. See the official SDK guide for those boundaries.

Issue Pulse counts every returned issue whose status is not done or cancelled. It also counts blocked and in_review separately. That definition includes backlog work, so the widget labels the number “Open issues.” The counts reflect the issues returned by the host for the selected company.

You will need

  • Node.js 24.11 or newer and the pnpm version required by your checkout. The reviewed revision pins pnpm 9.15.4.
  • A local Paperclip source checkout with dependencies installed, plus a development instance you run yourself.
  • Basic TypeScript and React knowledge.
  • A test company with a few issues; a second company helps verify isolation.

Follow the source-checkout quickstart (opens in a new tab) to clone Paperclip and install its dependencies. Local-path installs require the Paperclip server to read the plugin folder. With a remote server or container, a path on your laptop is not automatically available there.

Local plugins run trusted code with the server’s operating-system access. Capability declarations govern host API access; they do not sandbox a worker. Use a development instance and install code you control. The local development guide explains this trust model.

Create the project

In terminal one, start Paperclip from your source checkout. Replace the checkout path below with your own; leave this terminal running.

Terminal 1 · Paperclip checkout
cd /absolute/path/to/paperclip
pnpm paperclipai run

In terminal two, return to that same checkout and scaffold the plugin. Using its CLI and --sdk-path keeps the generated package aligned with your host instead of assuming an npm SDK release matches it.

Terminal 2 · Paperclip checkout
cd /absolute/path/to/paperclip
pnpm paperclipai plugin init @acme/issue-pulse \
  --output "$HOME/dev/paperclip-plugins" \
  --template default \
  --category ui \
  --display-name "Issue Pulse" \
  --description "Open issue counts for the selected company." \
  --sdk-path "$PWD/packages/plugins/sdk"

The result is ~/dev/paperclip-plugins/issue-pulse. Keep the generated package, SDK snapshot and build configuration. Replace the starter manifest, worker, widget and test with the files below, and add src/summary.ts. The ZIP contains those replacement files and a README; it is an overlay for the scaffold, not a standalone npm package.

The files and their responsibilities
FilePurpose
src/manifest.tsIdentity, permissions and the dashboard slot.
src/worker.tsRegister a data handler and read company issues.
src/summary.tsA shared type for the worker response and UI.
src/ui/index.tsxExport the component named in the manifest.
tests/plugin.spec.tsExercise behavior through the SDK test harness.

The generated package.json has a paperclipPlugin field pointing at the built manifest, worker and UI. Keep it aligned with the manifest’s entrypoints. Paperclip loads compiled output; it does not compile your TypeScript. Check the official scaffold source (opens in a new tab) when adapting the build.

Declare the manifest and permissions

Replace src/manifest.ts with this manifest. The package name is @acme/issue-pulse; the stable plugin ID is acme.issue-pulse. Replace the example scope and author before distributing your own version.

src/manifest.ts
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";

const manifest: PaperclipPluginManifestV1 = {
  id: "acme.issue-pulse",
  apiVersion: 1,
  version: "0.1.0",
  displayName: "Issue Pulse",
  description: "A read-only issue summary for the active company.",
  author: "Your name",
  categories: ["ui"],
  capabilities: ["issues.read", "ui.dashboardWidget.register"],
  entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
  ui: {
    slots: [{
      type: "dashboardWidget",
      id: "issue-pulse",
      displayName: "Issue Pulse",
      exportName: "IssuePulseWidget"
    }]
  }
};

export default manifest;

Only two capabilities are needed: issues.read for the worker and ui.dashboardWidget.register for the widget slot. The slot’s exportName must match the named React export exactly. Extra capabilities make the plugin harder to review and can change the operator’s upgrade approval requirements.

The manifest contract is defined in Paperclip’s shared plugin types (opens in a new tab). Keep the generated configuration schema if you later add settings; this version has no configurable secret or external destination.

Read issues in the worker

Add the shared response type first. The UI imports it with import type, so it does not pull worker code into the browser bundle.

src/summary.ts
export type IssueSummary = {
  companyId: string;
  total: number;
  open: number;
  counts: Record<string, number>;
  checkedAt: string;
};

Now replace src/worker.ts. The worker registers a data key, validates its input, queries issues for that company and returns only the counts the UI needs.

src/worker.ts
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import type { IssueSummary } from "./summary.js";

const plugin = definePlugin({
  async setup(ctx) {
    ctx.data.register("issue-summary", async (params): Promise<IssueSummary> => {
      const companyId = params.companyId;
      if (typeof companyId !== "string" || !companyId.trim()) {
        throw new Error("Select a company before loading Issue Pulse.");
      }

      const issues = await ctx.issues.list({ companyId });
      const counts: Record<string, number> = {
        backlog: 0, todo: 0, in_progress: 0, in_review: 0,
        blocked: 0, done: 0, cancelled: 0
      };
      let open = 0;
      for (const issue of issues) {
        counts[issue.status] = (counts[issue.status] ?? 0) + 1;
        if (issue.status !== "done" && issue.status !== "cancelled") open++;
      }
      return { companyId, total: issues.length, open, counts, checkedAt: new Date().toISOString() };
    });
  }
});

export default plugin;
runWorker(plugin, import.meta.url);

The company ID travels with every request. Avoid storing one global “current company” in the worker: the same plugin can serve multiple companies. Passing a company ID scopes the query; it is not a substitute for the host’s authorization checks.

This small-company example reads the returned issue list once. The worker has no polling timer and keeps no stored count cache. For a large company, measure the query cost and design aggregation or event-driven state before adopting it. Do not add pagination without verifying the host’s behavior and testing for skipped or duplicated issues.

The SDK context types (opens in a new tab) define ctx.issues.list and data registration. definePlugin (opens in a new tab) declares the worker; runWorker (opens in a new tab) connects it to the host lifecycle.

Render the dashboard widget

Replace src/ui/index.tsx. The host supplies context.companyId; usePluginData calls the worker’s issue-summary handler. Both sides must use the same key and response type.

src/ui/index.tsx
import { usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
import type { IssueSummary } from "../summary.js";

export function IssuePulseWidget({ context }: PluginWidgetProps) {
  const { data, loading, error, refresh } = usePluginData<IssueSummary>(
    "issue-summary", { companyId: context.companyId }
  );

  if (!context.companyId) return <p>Select a company to view its issues.</p>;
  if (loading) return <p role="status">Loading issue summary…</p>;
  if (error) return (
    <div>
      <p role="alert">Issue Pulse could not load. Try again or check the plugin worker.</p>
      <button type="button" onClick={() => void refresh()}>Try again</button>
    </div>
  );

  if (!data || data.companyId !== context.companyId) {
    return <p role="status">Loading issue summary…</p>;
  }

  return (
    <section aria-label="Issue Pulse">
      <h2>Issue Pulse</h2>
      <p>{data.total === 0 ? "No issues in this company." : `${data.total} issues visible to this plugin`}</p>
      <dl>
        <div><dt>Open issues</dt><dd>{data.open}</dd></div>
        <div><dt>Blocked</dt><dd>{data.counts.blocked}</dd></div>
        <div><dt>In review</dt><dd>{data.counts.in_review}</dd></div>
      </dl>
      <p>Checked: <time dateTime={data.checkedAt}>{new Date(data.checkedAt).toLocaleString()}</time></p>
      <button type="button" onClick={() => void refresh()}>Refresh</button>
    </section>
  );
}

Call the hook on every render, then choose what to display. A missing company, an in-flight request, a failure and an empty company need different messages. A failed request must never look like zero open issues. The retry button asks for fresh data; the widget does not promise live updates while it sits open.

Keep secrets and host clients in the worker. The browser receives only this summary. The UI hook implementation (opens in a new tab) and widget props (opens in a new tab) document the contract used here.

Test the company boundary

Replace the generated test rather than leaving starter assertions that still expect the scaffold’s demo data. These tests seed two companies, check the chosen status definition, verify an empty company and reject a missing company ID.

tests/plugin.spec.ts
import { describe, expect, it, vi } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
import type { IssueSummary } from "../src/summary.js";

async function ready() {
  const harness = createTestHarness({ manifest });
  await plugin.definition.setup(harness.ctx);
  return harness;
}

async function seed() {
  // Only this fixture builder can create issues; the tested plugin stays read-only.
  const fixtures = createTestHarness({
    manifest, capabilities: [...manifest.capabilities, "issues.create"]
  });
  const issues = await Promise.all([
    fixtures.ctx.issues.create({ companyId: "co-a", title: "First", status: "todo" }),
    fixtures.ctx.issues.create({ companyId: "co-a", title: "Second", status: "todo" }),
    fixtures.ctx.issues.create({ companyId: "co-a", title: "Review", status: "in_review" }),
    fixtures.ctx.issues.create({ companyId: "co-a", title: "Backlog", status: "backlog" }),
    fixtures.ctx.issues.create({ companyId: "co-a", title: "Completed", status: "done" }),
    fixtures.ctx.issues.create({ companyId: "co-a", title: "Cancelled", status: "cancelled" }),
    fixtures.ctx.issues.create({ companyId: "co-b", title: "Other company", status: "blocked" })
  ]);
  const harness = await ready();
  harness.seed({ issues });
  return harness;
}

describe("Issue Pulse", () => {
  it("counts only the requested company's visible issues", async () => {
    const harness = await seed();
    const data = await harness.getData<IssueSummary>("issue-summary", { companyId: "co-a" });
    expect(data.total).toBe(6);
    expect(data.open).toBe(4);
    expect(data.counts.backlog).toBe(1);
    expect(data.counts.done).toBe(1);
    expect(data.counts.cancelled).toBe(1);
    expect(data.counts.todo).toBe(2);
    expect(data.counts.in_review).toBe(1);
    expect(data.counts.blocked).toBe(0);
    expect(Number.isNaN(Date.parse(data.checkedAt))).toBe(false);
  });

  it("switches company without reusing another company's counts", async () => {
    const harness = await seed();
    await harness.getData("issue-summary", { companyId: "co-a" });
    const data = await harness.getData<IssueSummary>("issue-summary", { companyId: "co-b" });
    expect(data.total).toBe(1);
    expect(data.open).toBe(1);
    expect(data.counts.blocked).toBe(1);
    expect(data.counts.todo).toBe(0);
  });

  it("returns genuine zeros for an empty company", async () => {
    const harness = await ready();
    const data = await harness.getData<IssueSummary>("issue-summary", { companyId: "co-empty" });
    expect(data.total).toBe(0);
    expect(data.open).toBe(0);
    expect(Object.values(data.counts).every((count) => count === 0)).toBe(true);
  });

  it.each([undefined, "", "  ", 42])("rejects missing or invalid company scope: %s", async (companyId) => {
    const harness = await ready();
    await expect(harness.getData("issue-summary", { companyId })).rejects.toThrow("Select a company");
  });

  it("propagates host failures instead of reporting zero issues", async () => {
    const harness = await ready();
    vi.spyOn(harness.ctx.issues, "list").mockRejectedValueOnce(new Error("Host unavailable"));
    await expect(harness.getData("issue-summary", { companyId: "co-a" })).rejects.toThrow("Host unavailable");
  });

  it("cannot read issues without the issues.read capability", async () => {
    const harness = createTestHarness({ manifest, capabilities: ["ui.dashboardWidget.register"] });
    await plugin.definition.setup(harness.ctx);
    await expect(harness.getData("issue-summary", { companyId: "co-a" })).rejects.toThrow("issues.read");
  });
});

The official test harness (opens in a new tab) runs the worker definition with mock host services. It is useful for these behavior checks, but does not start a real worker process, mount the widget or reproduce every host query detail. Pair it with typechecking, a production build and the local acceptance check below.

Terminal 2 · Plugin folder
cd "$HOME/dev/paperclip-plugins/issue-pulse"
pnpm install
pnpm typecheck
pnpm test
pnpm build

The example’s validation record covers compilation, bundling and harness tests against the pinned SDK. A successful build is one delivery state; a working install in your Paperclip instance is another.

Build, install and verify locally

After the checks pass, run pnpm dev in terminal two from the plugin folder. It watches source files and rebuilds dist. In terminal three, use the CLI from your Paperclip checkout to install the folder into the local instance. Change --api-base if your instance uses a different address.

Terminal 3 · Paperclip checkout
cd /absolute/path/to/paperclip
pnpm paperclipai plugin install "$HOME/dev/paperclip-plugins/issue-pulse" \
  --local --api-base http://127.0.0.1:3100
pnpm paperclipai plugin list --api-base http://127.0.0.1:3100
pnpm paperclipai plugin inspect acme.issue-pulse \
  --api-base http://127.0.0.1:3100

In Settings → Plugins, inspect Issue Pulse, review its two capabilities and check for ready status. Then open the dashboard for your test company and verify:

  1. The displayed counts match that company’s visible issues, including backlog work in “Open issues.”
  2. Switching to your second company shows its counts, without values from the first.
  3. A company without issues gets the empty message.
  4. A worker failure gets an error and retry action, rather than a zero count.
  5. After a source edit, the watch build writes new output and the worker reloads; remount or reload the UI when needed.

Changes to source files alone do not trigger the host watcher: it observes built entrypoints. Manifest edits may affect permissions or reload behavior, so inspect status again after changing capabilities. See the CLI plugin reference for lifecycle commands.

To pause the experiment, disable it. This stops the worker while preserving configuration.

Terminal 3 · Pause the example
pnpm paperclipai plugin disable acme.issue-pulse \
  --api-base http://127.0.0.1:3100

You now have a reusable starting point: manifest, data handler, widget and tests. Change the question the widget answers before adding more infrastructure.

Prepare for real users

Keep access and data narrow

Request only the capabilities you use. Validate data-handler inputs and keep every query, cache key and stored record tied to a company. If you add a write action, make it explicit in the UI and test duplicate submissions, failures and permission denial. Never treat a count-only widget as permission to automate issue changes.

Keep secrets out of the UI

For a later API integration, store a secret reference in configuration and resolve it with ctx.secrets.resolve inside the worker. Do not ship tokens in a React bundle, return them from data handlers or include them in logs. Declare the required secret and HTTP capabilities deliberately. Read the SDK secrets and HTTP sections before adding network calls.

Make repeated work safe

An event handler can be retried, and a scheduled job can overlap with a restart. If you extend this plugin to send reminders or create issues, use a stable deduplication key, bounded retries and a documented recovery path. Keep durable state in host-managed plugin state or a managed database; worker memory disappears on restart. Test the second delivery of the same event.

Package what you tested

Pin the SDK and shared versions, record the Paperclip revision you checked and document a supported host version. Keep package.json and manifest versions in sync. Inspect npm pack --dry-run before release: the tarball needs the built entrypoints and must not contain credentials or local-only paths.

The scaffold may use local .paperclip-sdk tarballs during development. Do not publish a package that depends on files omitted from its tarball. Resolve a supported distribution strategy first, then test a packed install on a clean instance. Source inspection, a build and an installed runtime check should be reported separately.

Give operators enough information

Write a README with setup, capabilities, data handling, compatibility, uninstall behavior and a troubleshooting path. Add a license and a changelog. Capture a real screenshot of your widget once installed; label any sample data. Our submission page asks for a public repository and a real UI screenshot.

Good next versions of Issue Pulse could show overdue work, group blockers by project or link to a filtered issue list. Start with a read-only view. Add notifications only once you have tested company-scoped preferences, deduplication and delivery failures.

Fix common setup problems

The install reports an error
Run plugin inspect acme.issue-pulse against the correct instance. Build the package, then check that every paperclipPlugin entry points to an existing file or directory. Read the validation error before changing the manifest.
The worker is ready, but no widget appears
Check the dashboard slot, ui.dashboardWidget.register and the exact IssuePulseWidget export name. Verify the UI bundle exists, then reload the dashboard. Inspect the host’s logs if discovery still fails.
Edits do not show up
Keep pnpm dev running and confirm dist changes. A server watcher cannot see an unbuilt source edit. UI bundles reload on mount; reload the page when checking a new build.
Counts look wrong
Check the selected company, returned issue scope and the status definition. Backlog issues are open in this example; done and cancelled issues are excluded. Retry after changing issues. For a large dataset, verify completeness before adding a limit.
The server cannot read the local path
Use an absolute directory path that exists on the server’s filesystem. For containers, verify the mount and file permissions. Do not point the install command at a source file or at a path available only on your laptop.

Official sources and further reading

Looking for a plugin to use instead? Read our guide to ten Paperclip extensions or browse the complete directory.