feat(skills): add beta releases for the core Paperclip skill (#10228)

## Thinking Path

> - Paperclip is the open source control plane people use to organize
and operate AI-agent companies.
> - Agent behavior depends partly on the bundled Paperclip core skill
synchronized into each runtime.
> - The existing database and runtime plumbing already supports
immutable skill-version snapshots and per-agent version selections, but
no product workflow exposed that capability.
> - Replacing the live bundled skill globally would make champion
adoption risky and difficult to compare across agents.
> - This pull request adds an experimental, instance-level beta-skills
gate plus a repository release registry, immutable seeded releases,
enforcement, and a per-agent release picker.
> - The benefit is controlled per-agent evaluation of frozen core-skill
releases while the default-off path remains behaviorally unchanged.

## Linked Issues or Issue Description

### Subsystem affected

Cross-cutting: `server/`, `ui/`, `packages/db`, and `packages/shared`.

### Problem or motivation

Paperclip needs a safe way to evaluate improved versions of its core
operating skill without globally replacing the live default. Today the
version-snapshot and per-agent pin plumbing exists, but operators cannot
use it. A global replacement would make regressions difficult to contain
and would prevent controlled comparisons across agents.

### Proposed solution

Add a default-off instance experiment that exposes immutable, named
core-skill releases. When enabled, operators can pin each agent to a
seeded release; when disabled, every agent resolves the live default
while saved pins remain intact. Validate pinned writes at the API
boundary, gate reads at runtime, and expose the selection in the agent
Skills tab.

### Alternatives considered

- **Replace the bundled core skill globally:** rejected because it
changes every agent at once and provides no rollback/isolation boundary.
- **Ship releases as separate skills:** rejected because releases are
versions of one core capability, not independently enabled skills.
- **Store release snapshots only outside the repository:** rejected
because repository provenance and hashes make builds reproducible and
reviewable.

### Roadmap alignment

This extends the Skills Manager / Skill Studio direction in `ROADMAP.md`
by making core-skill versions operable per agent. It does not duplicate
another open implementation PR; GitHub searches found no related
`enableBetaSkills` change.

### Additional context

The feature remains experimental and default off. The V7 champion was
selected through a multi-model evaluation process, and the frozen
release contents are verified by SHA-256 below.

## What Changed

- Added the default-off instance-level `enableBetaSkills` experimental
flag.
- Added `skills-releases/paperclip/` with the ordered release registry
and frozen `v0` plus `v7-roster` snapshots.
- Added release metadata to `company_skill_versions` and idempotent
release seeding. The migration was planned as `0191`, then renumbered to
`0192` because current `master` claimed `0191` before final rebase.
- Added read-time gating and write-time validation so disabled instances
always resolve the live default and reject pinned-version writes.
- Added the per-agent Release picker in the agent Skills tab, including
responsive layout and beta-pin state.
- Kept `EDITS.md` out of the release registry and PR diff.

### V7 Adoption Evidence

- Paid roster: 6 models, 94-case suite.
- Result: 553/564 pass-within-2, mean 92.17/94, versus the P2 baseline
of 544/564.
- Reference model improved 84→91; maximin improved 84→90.
- Final report:
https://pages.paperclip.ing/skills/optimization/paperclip/pap-14624-p3-final-20260721/

### Provenance

- `v7-roster` is the Phase 1 champion plus additions-only edits
E107–E112. Per-edit rationale remains in the evals repository at
`source/v7-roster/EDITS.md` and is deliberately excluded from this PR.
- `v0` is the `skills/paperclip` tree from commit `ea66ea81`.
- Champion selection was accepted on July 21, 2026 via board card
`9c304fc2` (PAP-14624 G3).
- This delivery mechanism was accepted on July 24, 2026 via plan
revision `2367abd2` (PAP-14858).

### QA Evidence

- P4 QA matrix comment `b7f40522-4e9b-4a3a-9821-28e86fe1a987`: all 6
acceptance criteria passed.
- Automated QA matrix: 166 tests passed with 0 failures, including real
filesystem materialization and full SHA-256 assertions.
- UI QA exercised the real agent Skills tab at desktop and mobile widths
with the experimental flag both on and off.

## Verification

- `pnpm check:token-gates`
- Focused beta-release matrix: 169 tests passed across shared
validators, server services/routes/heartbeat behavior, instance settings
UI, and release picker UI.
- `pnpm -r typecheck`
- `pnpm build`
- `pnpm test:run`: server and UI partitions passed; one CLI doctor test
inherited temporary AWS credentials from the agent heartbeat and
expected no static credentials. The isolated rerun with
`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`
unset passed 8/8.
- V7 SHA-256:
- `SKILL.md`:
`53ab290489684cbf116fdd1406a95f6b6f53c9c36358b1bf8bfeae481e253575`
- `references/cases.md`:
`3b821f59064a7761091020a14819a8d787131f24029748563d6c0e1be7e6eaec`
- `references/workflows.md`:
`69747bd6e05f7e3673d1e67b07ff295df1869c05e1fd029804d5fa9177db92cd`
- Confirmed 49 changed files, no `pnpm-lock.yaml`, no workflow changes,
and no `EDITS.md`.

## Risks

- **Migration:** low-to-moderate risk. Three nullable columns and one
partial unique index are added idempotently; existing rows remain valid.
- **Behavior:** low risk while the flag is off because read-time
resolution forces the live default and saved pins are preserved but
inactive.
- **Frozen content:** release snapshots intentionally diverge from
future live skill edits; provenance and hashes make that divergence
explicit and reproducible.
- **UI:** low risk. The picker only renders for the bundled core skill
when the experimental flag is enabled and seeded releases exist.

> This extends the existing Skills Manager / Skill Studio direction
described in `ROADMAP.md`; it does not duplicate another open
implementation PR. The GitHub PR search found no related
`enableBetaSkills` change.

## Model Used

- OpenAI Codex using `gpt-5.5` with reasoning and
terminal/code-execution tools; context-window size is not exposed by
this runtime. Earlier implementation commits also record Claude Opus 4.8
assistance where applicable.

## 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
- [ ] I have not referenced internal/instance-local Paperclip issues or
links (required governance identifiers are included above; no internal
URL is included)
- [ ] My branch name describes the change and contains no internal
Paperclip ticket id (the approved delivery plan mandated this shared
branch name)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-27 19:45:59 -05:00 committed by GitHub
parent 030dd9d15c
commit c3bd0c5d50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 7394 additions and 33 deletions

View File

@ -0,0 +1,4 @@
ALTER TABLE "company_skill_versions" ADD COLUMN IF NOT EXISTS "release_id" text;--> statement-breakpoint
ALTER TABLE "company_skill_versions" ADD COLUMN IF NOT EXISTS "release_name" text;--> statement-breakpoint
ALTER TABLE "company_skill_versions" ADD COLUMN IF NOT EXISTS "released_at" timestamp with time zone;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "company_skill_versions_skill_release_idx" ON "company_skill_versions" USING btree ("company_skill_id","release_id") WHERE "company_skill_versions"."release_id" is not null;

View File

@ -1345,6 +1345,13 @@
"when": 1785170000000,
"tag": "0193_document_memberships",
"breakpoints": true
},
{
"idx": 194,
"version": "7",
"when": 1784920485226,
"tag": "0194_company_skill_releases",
"breakpoints": true
}
]
}

View File

@ -9,6 +9,7 @@ import {
integer,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
@ -73,6 +74,9 @@ export const companySkillVersions = pgTable(
companySkillId: uuid("company_skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }),
revisionNumber: integer("revision_number").notNull(),
label: text("label"),
releaseId: text("release_id"),
releaseName: text("release_name"),
releasedAt: timestamp("released_at", { withTimezone: true }),
fileInventory: jsonb("file_inventory").$type<CompanySkillVersionFileInventoryEntry[]>().notNull().default([]),
authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }),
authorUserId: text("author_user_id"),
@ -83,6 +87,9 @@ export const companySkillVersions = pgTable(
table.companySkillId,
table.revisionNumber,
),
companySkillReleaseUniqueIdx: uniqueIndex("company_skill_versions_skill_release_idx")
.on(table.companySkillId, table.releaseId)
.where(sql`${table.releaseId} is not null`),
companySkillCreatedIdx: index("company_skill_versions_company_skill_created_idx").on(
table.companyId,
table.companySkillId,

View File

@ -159,6 +159,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableBetaSkills: {
title: "Beta skills",
description: "Allow agents to pin beta releases of the Paperclip core skill.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableSummaries: {
title: "Summaries",
description:

View File

@ -159,6 +159,9 @@ export interface CompanySkillVersion {
companySkillId: string;
revisionNumber: number;
label: string | null;
releaseId: string | null;
releaseName: string | null;
releasedAt: Date | null;
fileInventory: CompanySkillVersionFileInventoryEntry[];
authorAgentId: string | null;
authorUserId: string | null;

View File

@ -59,6 +59,7 @@ export interface InstanceExperimentalSettings {
enableExternalObjects: boolean;
enableSmokeLab: boolean;
enableBuiltInAgents: boolean;
enableBetaSkills: boolean;
enableSummaries: boolean;
enableStatusCards: boolean;
enableDecisions: boolean;

View File

@ -50,6 +50,12 @@ describe("instance experimental settings validators", () => {
expect(settings.enableBuiltInAgents).toBe(false);
});
it("defaults beta skills off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});
expect(settings.enableBetaSkills).toBe(false);
});
it("defaults apps off", () => {
const settings = instanceExperimentalSettingsSchema.parse({});

View File

@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableExternalObjects: z.boolean().default(false),
enableSmokeLab: z.boolean().default(false),
enableBuiltInAgents: z.boolean().default(false),
enableBetaSkills: z.boolean().default(false),
enableSummaries: z.boolean().default(false),
enableStatusCards: z.boolean().default(false),
enableDecisions: z.boolean().default(false),

View File

@ -48,6 +48,10 @@ const mockCompanySkillService = vi.hoisted(() => ({
resolveRequestedSkillKeys: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
@ -102,6 +106,11 @@ vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/instance-settings.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../services/instance-settings.js")>()),
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
@ -140,6 +149,11 @@ function registerModuleMocks() {
secretService: () => mockSecretService,
}));
vi.doMock("../services/instance-settings.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../services/instance-settings.js")>()),
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(() => mockAdapter),
findActiveServerAdapter: vi.fn(() => mockAdapter),
@ -245,6 +259,7 @@ describe.sequential("agent skill routes", () => {
for (const mock of Object.values(mockIssueApprovalService)) mock.mockReset();
for (const mock of Object.values(mockAgentInstructionsService)) mock.mockReset();
for (const mock of Object.values(mockCompanySkillService)) mock.mockReset();
for (const mock of Object.values(mockInstanceSettingsService)) mock.mockReset();
for (const mock of Object.values(mockSecretService)) mock.mockReset();
mockLogActivity.mockReset();
mockTrackAgentCreated.mockReset();
@ -260,6 +275,7 @@ describe.sequential("agent skill routes", () => {
agent: makeAgent("claude_local"),
});
mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: false });
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([
{
@ -599,6 +615,48 @@ describe.sequential("agent skill routes", () => {
);
});
it("rejects version pins while beta skills are disabled", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
.send({
desiredSkills: [{
key: "paperclipai/paperclip/paperclip",
versionId: "22222222-2222-4222-8222-222222222222",
}],
}));
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(res.body.error).toContain("Beta skills experimental setting");
expect(mockAgentService.update).not.toHaveBeenCalled();
});
it("accepts version pins while beta skills are enabled", async () => {
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBetaSkills: true });
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
const versionId = "22222222-2222-4222-8222-222222222222";
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1")
.send({
desiredSkills: [{ key: "paperclipai/paperclip/paperclip", versionId }],
}));
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentService.update).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
adapterConfig: expect.objectContaining({
paperclipSkillSync: expect.objectContaining({
desiredSkills: [{ key: "paperclipai/paperclip/paperclip", versionId }],
}),
}),
}),
expect.any(Object),
);
});
it("preserves stale desired keys instead of 422-ing when syncing (PAP-13222)", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("acpx_local"));
// The agent already carries a stale desired key that no longer resolves to a
@ -806,6 +864,25 @@ describe.sequential("agent skill routes", () => {
);
});
it("rejects version pins when creating an agent while beta skills are disabled", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
desiredSkills: [{
key: "paperclipai/paperclip/paperclip",
versionId: "22222222-2222-4222-8222-222222222222",
}],
adapterConfig: {},
}));
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(res.body.error).toContain("Beta skills experimental setting");
expect(mockAgentService.create).not.toHaveBeenCalled();
});
it("accepts the security role on direct agent creation and preserves it in telemetry", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
@ -995,6 +1072,26 @@ describe.sequential("agent skill routes", () => {
);
});
it("rejects version pins in agent hires while beta skills are disabled", async () => {
const res = await request(await createApp(createDb(true)))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
desiredSkills: [{
key: "paperclipai/paperclip/paperclip",
versionId: "22222222-2222-4222-8222-222222222222",
}],
adapterConfig: {},
});
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(res.body.error).toContain("Beta skills experimental setting");
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockApprovalService.create).not.toHaveBeenCalled();
});
it("preserves hire source issues, icons, desired skills, and approval payload details", async () => {
const db = createDb(true);
const sourceIssueId = "22222222-2222-4222-8222-222222222222";

View File

@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
@ -333,6 +333,73 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString());
});
it("seeds bundled skill releases idempotently and materializes the frozen champion snapshot", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const initialList = await svc.list(companyId);
await svc.list(companyId);
const paperclipSkill = initialList.find((skill) => skill.key === "paperclipai/paperclip/paperclip");
expect(paperclipSkill).toBeDefined();
if (!paperclipSkill) throw new Error("Expected bundled Paperclip skill");
const versions = await svc.listVersions(companyId, paperclipSkill.id);
expect(versions.map((version) => version.releaseId).sort()).toEqual(["v0", "v7-roster"]);
expect(versions).toHaveLength(2);
const storedSkill = await db
.select({ currentVersionId: companySkills.currentVersionId })
.from(companySkills)
.where(eq(companySkills.id, paperclipSkill.id))
.then((rows) => rows[0]);
expect(storedSkill?.currentVersionId).toBeNull();
const champion = versions.find((version) => version.releaseId === "v7-roster");
expect(champion).toMatchObject({
releaseName: "V7 — Roster champion",
releasedAt: new Date("2026-07-21T00:00:00.000Z"),
});
if (!champion) throw new Error("Expected seeded v7-roster release");
const championHashes = Object.fromEntries(champion.fileInventory.map((entry) => [
entry.path,
createHash("sha256").update(entry.content).digest("hex"),
]));
expect(championHashes).toMatchObject({
"SKILL.md": "53ab290489684cbf116fdd1406a95f6b6f53c9c36358b1bf8bfeae481e253575",
"references/cases.md": "3b821f59064a7761091020a14819a8d787131f24029748563d6c0e1be7e6eaec",
"references/workflows.md": "69747bd6e05f7e3673d1e67b07ff295df1869c05e1fd029804d5fa9177db92cd",
});
expect(championHashes).not.toHaveProperty("EDITS.md");
const runtimeEntries = await svc.listRuntimeSkillEntries(companyId, {
versionSelections: new Map([[paperclipSkill.key, champion.id]]),
});
const materialized = runtimeEntries.find((entry) => entry.key === paperclipSkill.key);
expect(materialized).toMatchObject({ versionId: champion.id, sourceStatus: "available" });
if (!materialized) throw new Error("Expected materialized release entry");
const materializedHashes: Record<string, string> = {};
async function walk(root: string, current = root): Promise<void> {
for (const entry of await fs.readdir(current, { withFileTypes: true })) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
await walk(root, absolutePath);
continue;
}
const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
materializedHashes[relativePath] = createHash("sha256")
.update(await fs.readFile(absolutePath))
.digest("hex");
}
}
await walk(materialized.source);
expect(materializedHashes).toEqual(championHashes);
expect(materializedHashes).not.toHaveProperty("EDITS.md");
});
it("repairs a squatted bundled root during bundled-skill list refresh", async () => {
const companyId = randomUUID();
await db.insert(companies).values({

View File

@ -24,6 +24,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { companySkillService } from "../services/company-skills.ts";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@ -110,6 +111,7 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
afterEach(async () => {
capturedRuns.length = 0;
await instanceSettingsService(db).updateExperimental({ enableBetaSkills: false });
await new Promise((resolve) => setTimeout(resolve, 100));
await db.execute(sql.raw(`
TRUNCATE TABLE
@ -228,6 +230,9 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
},
]);
const settings = instanceSettingsService(db);
await settings.updateExperimental({ enableBetaSkills: true });
const heartbeat = heartbeatService(db);
const firstRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual");
expect(firstRun).not.toBeNull();
@ -276,6 +281,49 @@ describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
sourceStatus: "available",
});
expect((await fs.stat(firstSkillFile)).mtime.toISOString()).toBe(oldMtime.toISOString());
await settings.updateExperimental({ enableBetaSkills: false });
const defaultRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual");
expect(defaultRun).not.toBeNull();
expect((await waitForRunToFinish(heartbeat, defaultRun!.id))?.status).toBe("succeeded");
const defaultSkill = capturedRuns
.filter((run) => run.agentId === firstAgentId)
.at(-1)
?.skills.find((entry) => entry.key === skillKey);
expect(defaultSkill).toMatchObject({
key: skillKey,
versionId: null,
currentVersionId: versionTwo.id,
sourceStatus: "available",
});
await expect(fs.readFile(path.join(defaultSkill!.source, "SKILL.md"), "utf8"))
.resolves.toContain("Version two.");
const storedPreference = await db
.select({ adapterConfig: agents.adapterConfig })
.from(agents)
.where(eq(agents.id, firstAgentId))
.then((rows) => rows[0]?.adapterConfig);
expect(storedPreference).toMatchObject({
paperclipSkillSync: {
desiredSkills: [{ key: skillKey, versionId: versionOne.id }],
},
});
await settings.updateExperimental({ enableBetaSkills: true });
const restoredRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual");
expect(restoredRun).not.toBeNull();
expect((await waitForRunToFinish(heartbeat, restoredRun!.id))?.status).toBe("succeeded");
const restoredSkill = capturedRuns
.filter((run) => run.agentId === firstAgentId)
.at(-1)
?.skills.find((entry) => entry.key === skillKey);
expect(restoredSkill).toMatchObject({
versionId: versionOne.id,
currentVersionId: versionTwo.id,
sourceStatus: "available",
});
await expect(fs.readFile(path.join(restoredSkill!.source, "SKILL.md"), "utf8"))
.resolves.toContain("Version one.");
});
it("delivers installed connections without exposing gateway bearers in adapter config or logs", async () => {

View File

@ -83,6 +83,7 @@ describe("instance settings routes", () => {
enableCloudSync: false,
enableExternalObjects: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
@ -111,6 +112,7 @@ describe("instance settings routes", () => {
enableCloudSync: false,
enableExternalObjects: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
@ -138,6 +140,7 @@ describe("instance settings routes", () => {
enableCloudSync: true,
enableExternalObjects: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
@ -232,6 +235,7 @@ describe("instance settings routes", () => {
enableCloudSync: false,
enableExternalObjects: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,

View File

@ -39,6 +39,7 @@ describe("instance settings service", () => {
enableTaskWatchdogs: true,
enableCloudSync: true,
enableBuiltInAgents: true,
enableBetaSkills: false,
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,
@ -150,6 +151,12 @@ describe("instance settings service", () => {
expect(normalizeExperimentalSettings({ enableExternalObjects: true }).enableBuiltInAgents).toBe(false);
});
it("preserves enableBetaSkills and defaults it off for legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableBetaSkills).toBe(false);
expect(normalizeExperimentalSettings({}).enableBetaSkills).toBe(false);
expect(normalizeExperimentalSettings({ enableBetaSkills: true }).enableBetaSkills).toBe(true);
});
it("sets worktree run execution activation fields on a false to true transition", () => {
const activatedAt = new Date("2026-07-10T12:00:00.000Z");

View File

@ -53,7 +53,7 @@ import {
syncInstructionsBundleConfigFromFilePath,
workspaceOperationService,
} from "../services/index.js";
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
@ -1602,10 +1602,13 @@ export function agentRoutes(
} = {},
) {
const preference = readPaperclipSkillSyncPreference(config);
const betaSkillsEnabled = (await instanceSettings.getExperimental()).enableBetaSkills === true;
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: options.materializeMissing
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries, {
versionPinsEnabled: betaSkillsEnabled,
}),
});
return {
...config,
@ -1629,6 +1632,13 @@ export function agentRoutes(
};
}
if (requestedDesiredSkills.some((entry) => entry.versionId !== null)) {
const betaSkillsEnabled = (await instanceSettings.getExperimental()).enableBetaSkills === true;
if (!betaSkillsEnabled) {
throw badRequest("Beta skill version pins require the Beta skills experimental setting to be enabled.");
}
}
const { resolved: resolvedRequestedSkillEntries, unresolved: unresolvedDesiredSkillKeys } =
await companySkills.resolveRequestedSkillEntries(companyId, requestedDesiredSkills, {
tolerateUnknownReferences: options.tolerateUnknownDesiredSkills,

View File

@ -366,6 +366,22 @@ type SkillActor = {
userId?: string | null;
};
type BundledSkillReleaseManifestEntry = {
id: string;
releaseName: string;
releasedAt: string;
notes: string;
dir: string;
};
type CreateVersionOptions = {
fileInventory?: CompanySkillVersionFileInventoryEntry[];
release?: { id: string; name: string; releasedAt: Date };
updateCurrentVersion?: boolean;
skipInventoryRefresh?: boolean;
skill?: CompanySkill;
};
type PlannedSkillReassignment = {
agentId: string;
reassignment: CompanySkillForkReassignment;
@ -886,6 +902,15 @@ function resolveBundledSkillsRoot() {
];
}
function resolveBundledSkillReleasesRoot() {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
return [
path.resolve(moduleDir, "../../skills-releases/paperclip"),
path.resolve(process.cwd(), "skills-releases/paperclip"),
path.resolve(moduleDir, "../../../skills-releases/paperclip"),
];
}
function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: string | null) {
if (!requestedSkillSlug) return true;
const skillDir = path.posix.dirname(relativeSkillPath);
@ -1783,6 +1808,9 @@ function toCompanySkillVersion(row: CompanySkillVersionRow): CompanySkillVersion
return {
...row,
label: row.label ?? null,
releaseId: row.releaseId ?? null,
releaseName: row.releaseName ?? null,
releasedAt: row.releasedAt ?? null,
fileInventory: Array.isArray(row.fileInventory)
? row.fileInventory.flatMap((entry) => {
if (!isPlainRecord(entry)) return [];
@ -2828,6 +2856,89 @@ export function companySkillService(db: Db) {
return [];
}
async function readBundledSkillReleaseRegistry() {
for (const registryRoot of resolveBundledSkillReleasesRoot()) {
const manifestPath = path.join(registryRoot, "releases.json");
const manifestText = await fs.readFile(manifestPath, "utf8").catch(() => null);
if (!manifestText) continue;
const parsed = JSON.parse(manifestText) as unknown;
if (!Array.isArray(parsed)) throw new Error(`Invalid bundled skill release manifest: ${manifestPath}`);
return parsed.map((entry): BundledSkillReleaseManifestEntry & { releaseDir: string } => {
if (!isPlainRecord(entry)) throw new Error(`Invalid bundled skill release entry: ${manifestPath}`);
const id = asString(entry.id);
const releaseName = asString(entry.releaseName);
const releasedAt = asString(entry.releasedAt);
const notes = asString(entry.notes);
const dir = asString(entry.dir);
if (!id || !releaseName || !releasedAt || !notes || !dir) {
throw new Error(`Incomplete bundled skill release entry: ${manifestPath}`);
}
const releaseDir = path.resolve(registryRoot, dir);
const relativeReleaseDir = path.relative(registryRoot, releaseDir);
if (relativeReleaseDir.startsWith("..") || path.isAbsolute(relativeReleaseDir)) {
throw new Error(`Bundled skill release directory escapes registry root: ${dir}`);
}
return { id, releaseName, releasedAt, notes, dir, releaseDir };
});
}
return [];
}
async function collectVersionFileInventoryFromDirectory(
skillDir: string,
): Promise<CompanySkillVersionFileInventoryEntry[]> {
const inventory = await collectLocalSkillInventory(skillDir);
return Promise.all(inventory.map(async (entry) => ({
...entry,
content: await fs.readFile(path.join(skillDir, entry.path), "utf8"),
})));
}
async function ensureBundledSkillReleases(companyId: string, bundledSkills: CompanySkill[]) {
const paperclipSkill = bundledSkills.find((skill) => skill.key === "paperclipai/paperclip/paperclip");
if (!paperclipSkill) return;
for (const release of await readBundledSkillReleaseRegistry()) {
const fileInventory = serializeVersionFileInventory(
await collectVersionFileInventoryFromDirectory(release.releaseDir),
);
const existing = await db
.select()
.from(companySkillVersions)
.where(and(
eq(companySkillVersions.companyId, companyId),
eq(companySkillVersions.companySkillId, paperclipSkill.id),
eq(companySkillVersions.releaseId, release.id),
))
.then((rows) => rows[0] ?? null);
if (existing) {
const existingInventory = toCompanySkillVersion(existing).fileInventory;
const existingHash = buildInventoryContentHash(existingInventory.map((entry) => ({
path: entry.path,
sha256: sha256Buffer(entry.content),
})));
const registryHash = buildInventoryContentHash(fileInventory.map((entry) => ({
path: entry.path,
sha256: sha256Buffer(entry.content),
})));
if (existingHash !== registryHash) {
throw new Error(`Bundled skill release ${release.id} does not match its seeded snapshot.`);
}
continue;
}
const releasedAt = new Date(release.releasedAt);
if (Number.isNaN(releasedAt.getTime())) {
throw new Error(`Invalid bundled skill release date: ${release.releasedAt}`);
}
await createVersion(companyId, paperclipSkill.id, { label: release.releaseName }, null, {
fileInventory,
release: { id: release.id, name: release.releaseName, releasedAt },
updateCurrentVersion: false,
skipInventoryRefresh: true,
skill: paperclipSkill,
});
}
}
async function reconcilePaperclipSkillFolders(companyId: string) {
const shippedSkills = await db
.select({
@ -2951,7 +3062,8 @@ export function companySkillService(db: Db) {
if (!companyExists) {
throw notFound("Company not found");
}
await ensureBundledSkills(companyId);
const bundledSkills = await ensureBundledSkills(companyId);
await ensureBundledSkillReleases(companyId, bundledSkills);
await reconcilePaperclipSkillFolders(companyId);
await reconcileLocalPathSkillSources(companyId);
})();
@ -3343,11 +3455,14 @@ export function companySkillService(db: Db) {
skillId: string,
input: CompanySkillVersionCreateRequest = {},
actor: SkillActor | null = null,
options: CreateVersionOptions = {},
): Promise<CompanySkillVersion> {
await ensureSkillInventoryCurrent(companyId);
const skill = await getById(companyId, skillId);
if (!options.skipInventoryRefresh) await ensureSkillInventoryCurrent(companyId);
const skill = options.skill ?? await getById(companyId, skillId);
if (!skill) throw notFound("Skill not found");
const fileInventory = serializeVersionFileInventory(await collectVersionFileInventory(companyId, skill));
const fileInventory = serializeVersionFileInventory(
options.fileInventory ?? await collectVersionFileInventory(companyId, skill),
);
const versionRow = await db.transaction(async (tx) => {
await tx.execute(sql`
select ${companySkills.id}
@ -3369,6 +3484,9 @@ export function companySkillService(db: Db) {
companySkillId: skillId,
revisionNumber: Number(nextRevision ?? 1),
label: input.label?.trim() || null,
releaseId: options.release?.id ?? null,
releaseName: options.release?.name ?? null,
releasedAt: options.release?.releasedAt ?? null,
fileInventory,
authorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
authorUserId: actor?.type === "user" ? actor.userId ?? null : null,
@ -3376,10 +3494,12 @@ export function companySkillService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null);
if (!row) return null;
await tx
.update(companySkills)
.set({ currentVersionId: row.id, updatedAt: new Date() })
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)));
if (options.updateCurrentVersion !== false) {
await tx
.update(companySkills)
.set({ currentVersionId: row.id, updatedAt: new Date() })
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)));
}
return row;
});
if (!versionRow) throw notFound("Failed to persist skill version");

View File

@ -12417,7 +12417,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
const runtimeSkillPreference = readPaperclipSkillSyncPreference(effectiveResolvedConfig);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
versionSelections: skillVersionSelectionMap(runtimeSkillPreference.desiredSkillEntries),
versionSelections: skillVersionSelectionMap(runtimeSkillPreference.desiredSkillEntries, {
versionPinsEnabled: resolvedInstanceSettings.experimental.enableBetaSkills === true,
}),
});
let runtimeConfig: Record<string, unknown> = {
...effectiveResolvedConfig,

View File

@ -221,6 +221,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
enableBetaSkills: parsed.data.enableBetaSkills ?? false,
enableSummaries: parsed.data.enableSummaries ?? false,
enableStatusCards: parsed.data.enableStatusCards ?? false,
enableDecisions: parsed.data.enableDecisions ?? false,
@ -254,6 +255,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExternalObjects: false,
enableSmokeLab: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,

View File

@ -1,3 +1,7 @@
export function skillVersionSelectionMap(entries: Array<{ key: string; versionId: string | null }>) {
return new Map(entries.map((entry) => [entry.key, entry.versionId] as const));
export function skillVersionSelectionMap(
entries: Array<{ key: string; versionId: string | null }>,
options: { versionPinsEnabled?: boolean } = {},
) {
const versionPinsEnabled = options.versionPinsEnabled ?? true;
return new Map(entries.map((entry) => [entry.key, versionPinsEnabled ? entry.versionId : null] as const));
}

View File

@ -0,0 +1,16 @@
[
{
"id": "v0",
"releaseName": "V0 — Original",
"releasedAt": "2026-07-15T07:52:54-05:00",
"notes": "Original Paperclip core skill snapshot from upstream commit ea66ea81.",
"dir": "v0"
},
{
"id": "v7-roster",
"releaseName": "V7 — Roster champion",
"releasedAt": "2026-07-21",
"notes": "Adopted by PAP-14624 G3 confirmation card 9c304fc2. This frozen historical release predates three later master edits to SKILL.md, references/api-reference.md, and references/company-skills.md; the live default keeps the newer guidance.",
"dir": "v7-roster"
}
]

View File

@ -0,0 +1,487 @@
---
name: paperclip
description: >
Interact with the Paperclip control plane API for task coordination and
governance. Use when checking assignments, updating issue status, posting
comments, delegating work, managing routines, or calling Paperclip API
endpoints.
---
# Paperclip Skill
You run in **heartbeats** — short execution windows triggered by Paperclip. Each heartbeat, you wake up, check your work, do something useful, and exit. You do not run continuously.
## Terminology
In Paperclip, **task** and **issue** refer to the same work item. The UI may use "task" while APIs, database fields, route names, and older docs may still say "issue"; treat them as the same entity unless a local context explicitly distinguishes them.
## Authentication
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For sandbox-backed local adapters, the Bash/tool environment may receive `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` for a run-scoped bridge instead of the host API directly; use those exact env vars from Bash/curl and do not assume the host port is reachable from browser or web tools. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL, and never paste the API key or bridge token into prompts, comments, documents, restored workspace files, or logs.
Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides.
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
## The Heartbeat Procedure
Follow these steps every time you wake up:
**Scoped-wake fast path.** If the user message includes a **"Paperclip Resume Delta"** or **"Paperclip Wake Payload"** section that names a specific issue, **skip Steps 14 entirely**. Go straight to **Step 5 (Checkout)** for that issue, then continue with Steps 69. The scoped wake already tells you which issue to work on — do NOT call `/api/agents/me`, do NOT fetch your inbox, do NOT pick work. Just checkout, read the wake context, do the work, and update.
**Step 1 — Identity.** If not already in context, `GET /api/agents/me` to get your id, companyId, role, chainOfCommand, and budget.
**Step 2 — Approval follow-up (when triggered).** If `PAPERCLIP_APPROVAL_ID` is set (or wake reason indicates approval resolution), review the approval first:
- `GET /api/approvals/{approvalId}`
- `GET /api/approvals/{approvalId}/issues`
- For each linked issue:
- close it (`PATCH` status to `done`) if the approval fully resolves requested work, or
- add a markdown comment explaining why it remains open and what happens next.
Always include links to the approval and issue in that comment.
**Step 3 — Get assignments.** Prefer `GET /api/agents/me/inbox-lite` for the normal heartbeat inbox. It returns the compact assignment list you need for prioritization. Fall back to `GET /api/companies/{companyId}/issues?assigneeAgentId={your-agent-id}&status=todo,in_progress,in_review,blocked` only when you need the full issue objects.
**Step 4 — Pick work.** Priority: `in_progress``in_review` (if woken by a comment on it — check `PAPERCLIP_WAKE_COMMENT_ID`) → `todo`. Skip `blocked` unless you can unblock.
Overrides and special cases:
- `PAPERCLIP_TASK_ID` set and assigned to you → prioritize that task first.
- `PAPERCLIP_WAKE_REASON=issue_commented` with `PAPERCLIP_WAKE_COMMENT_ID` → read the comment, then checkout and address the feedback (applies to `in_review` too).
- `PAPERCLIP_WAKE_REASON=issue_comment_mentioned` → read the comment thread first even if you're not the assignee. Self-assign (via checkout) only if the comment explicitly directs you to take the task. Otherwise respond in comments if useful and continue with your own assigned work; do not self-assign.
- Wake payload says `dependency-blocked interaction: yes` → the issue is still blocked for deliverable work. Do not try to unblock it. Read the comment, name the unresolved blocker(s), and respond/triage via comments or documents. Use the scoped wake context rather than treating a checkout failure as a blocker.
- **Blocked-task dedup:** before touching a `blocked` task, check the thread. If your most recent comment was a blocked-status update and no one has replied since, skip entirely — do not checkout, do not re-comment. Only re-engage on new context (comment, status change, event wake).
- Nothing assigned and no valid mention handoff → exit the heartbeat.
**Step 5 — Checkout.** You MUST checkout before doing any work. Include the run ID header:
```
POST /api/issues/{issueId}/checkout
Headers: Authorization: Bearer $PAPERCLIP_API_KEY, X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
{ "agentId": "{your-agent-id}", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] }
```
If already checked out by you, returns normally. If owned by another agent: `409 Conflict` — stop, pick a different task. **Never retry a 409.**
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
Use comments incrementally:
- if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}`
- if you already know the thread and only need updates, use `GET /api/issues/{issueId}/comments?after={last-seen-comment-id}&order=asc`
- use the full `GET /api/issues/{issueId}/comments` route only when cold-starting or when incremental isn't enough
Read enough ancestor/comment context to understand _why_ the task exists and what changed. Do not reflexively reload the whole thread on every heartbeat.
**Execution-policy review/approval wakes.** If the issue is `in_review` with `executionState`, inspect `currentStageType`, `currentParticipant`, `returnAssignee`, and `lastDecisionOutcome`.
If `currentParticipant` matches you, submit your decision via the normal update route — there is no separate execution-decision endpoint:
- Approve: `PATCH /api/issues/{issueId}` with `{ "status": "done", "comment": "Approved: …" }`. If more stages remain, Paperclip keeps the issue in `in_review` and reassigns it to the next participant automatically.
- Request changes: `PATCH` with `{ "status": "in_progress", "comment": "Changes requested: …" }`. Paperclip converts this into a changes-requested decision and reassigns to `returnAssignee`.
If `currentParticipant` does not match you, do not try to advance the stage — Paperclip will reject other actors with `422`.
**Step 7 — Do the work.** Use your tools and capabilities. Execution contract:
- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.
- Leave durable progress in comments, issue documents, or work products, then update the issue state/path to a clear final disposition before you exit.
- Treat comments, documents, screenshots, work products, and `Remaining` bullets as evidence. They are not valid liveness paths by themselves.
- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.
- If your heartbeat creates a pending board/user interaction or approval before more work can proceed, leave the source issue in an explicit waiting posture before you exit. Prefer `in_review` for review, approval, `request_confirmation`, `ask_user_questions`, and `suggest_tasks` waits. Use `blocked` with `blockedByIssueIds` when another issue is the blocker.
- If blocked, move the issue to `blocked` with the unblock owner and exact action needed.
- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.
### Generated Artifacts and Work Products
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition and create an artifact work product. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
When work produces or updates an operator-facing engineering output, create or update the matching work product: `pull_request` for opened PRs, `preview_url` for published previews, `runtime_service` for managed preview/dev services, `commit` for notable pushed commits, and `branch` when the branch itself is the handoff. Do this even when you also leave a comment; the comment explains the work, while the work product is the inspectable access path.
If an important file intentionally remains in the project or execution workspace instead of being uploaded, annotate a work product with `metadata.resourceRef.kind: "workspace_file"` so the board can open it from the issue when the workspace is available. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path for deliverables.
For technical upload instructions, read `references/artifacts.md`.
**Step 8 — Update status and communicate.** Always include the run ID header.
If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act.
Before ending any heartbeat, apply this final-disposition checklist:
- `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue.
- `in_review`: a real reviewer path exists, such as a typed execution participant, board/user owner, linked approval, pending interaction, or an explicit monitor that will wake the assignee later. Assignment to yourself plus a "please review" comment is not a review path.
- `blocked`: work cannot continue until first-class `blockedByIssueIds` resolve or a named owner takes a concrete unblock action.
- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work.
- Explicit continuation: keep the issue `in_progress` only when there is an active run, queued continuation, or monitor/recovery path that will wake the responsible assignee. Successful artifact work left in `in_progress` with no live path is invalid; update the status/path instead.
When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below.
```json
PATCH /api/issues/{issueId}
Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
{ "status": "done", "comment": "What was done and why." }
```
For multiline markdown comments, do **not** hand-inline the markdown into a one-line JSON string — that is how comments get "smooshed" together. Use the helper below (or an equivalent `jq --arg` pattern reading from a heredoc/file) so literal newlines survive JSON encoding:
```bash
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD'
Done
- Fixed the newline-preserving issue update path
- Verified the raw stored comment body keeps paragraph breaks
MD
```
Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`, `blockedByIssueIds`.
### Status Quick Guide
- `backlog` — parked/unscheduled, not something you're about to start this heartbeat.
- `todo` — ready and actionable, but not checked out yet. Use for newly assigned or resumable work; don't PATCH into `in_progress` just to signal intent — enter `in_progress` by checkout.
- `in_progress` — actively owned, execution-backed work.
- `in_review` — paused pending reviewer/approver/board/user feedback. Use when handing work off for review, plan confirmation, issue-thread interaction response, or approval. This is a healthy waiting path, not a synonym for done. If a human asks to take the task back, reassign to them and set `in_review`.
- `blocked` — cannot proceed until something specific changes. Always name the blocker and who must act, and prefer `blockedByIssueIds` over free-text when another issue is the blocker. `parentId` alone does not imply a blocker.
- `done` — work complete, no follow-up on this issue.
- `cancelled` — intentionally abandoned, not to be resumed.
**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work.
## Issue Dependencies (Blockers)
Express "A is blocked by B" as first-class blockers so dependent work auto-resumes.
**Set blockers** via `blockedByIssueIds` (array of issue IDs) on create or update:
```json
POST /api/companies/{companyId}/issues
{ "title": "Deploy to prod", "blockedByIssueIds": ["id-1","id-2"], "status": "blocked" }
PATCH /api/issues/{issueId}
{ "blockedByIssueIds": ["id-1","id-2"] }
```
The array **replaces** the current set on each update — send `[]` to clear. Issues cannot block themselves; circular chains are rejected.
**Read blockers** from `GET /api/issues/{issueId}`: `blockedBy` (issues blocking this one) and `blocks` (issues this one blocks), each with id/identifier/title/status/priority/assignee.
**Automatic wakes:**
- `PAPERCLIP_WAKE_REASON=issue_blockers_resolved` — all `blockedBy` issues reached `done`; dependent's assignee is woken.
- `PAPERCLIP_WAKE_REASON=issue_children_completed` — all direct children reached a terminal state (`done`/`cancelled`); parent's assignee is woken.
`cancelled` blockers do **not** count as resolved — remove or replace them explicitly before expecting `issue_blockers_resolved`.
## Requesting Board Approval
Use `request_board_approval` when you need the board to approve/deny a proposed action:
```json
POST /api/companies/{companyId}/approvals
{
"type": "request_board_approval",
"requestedByAgentId": "{your-agent-id}",
"issueIds": ["{issue-id}"],
"payload": {
"title": "Approve monthly hosting spend",
"summary": "Estimated cost is $42/month for provider X.",
"recommendedAction": "Approve provider X and continue setup.",
"risks": ["Costs may increase with usage."]
}
}
```
`issueIds` links the approval into the issue thread. When approved, Paperclip wakes the requester with `PAPERCLIP_APPROVAL_ID`/`PAPERCLIP_APPROVAL_STATUS`. Keep the payload concise and decision-ready.
## Issue-Thread Interactions
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
Five kinds are supported. Pick the smallest kind that fits the decision shape:
| Kind | When to use | When **not** to use |
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. |
| `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
Key shared semantics:
- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the board resolves the selection or submits newly resolved item verdicts. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
- **Target binding and staleness.** `request_confirmation`, `request_checkbox_confirmation`, and `request_item_verdicts` accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
- **Supersede on user comment.** Target-bound request kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path.
Create a `request_checkbox_confirmation` (board selects any subset, then confirms):
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
When the board accepts, your wake delivers `result.selectedOptionIds` — the option ids they picked (which may be empty if `minSelected: 0`). Rejection delivers `result.reason` and a `commentId`.
For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**.
## MCP Tool Approval Gates
Some MCP tools are configured as **ask first**. Their `tools/list` description says that human approval is required. When you call one:
1. Paperclip posts one approval card on your checked-out task and returns `approval_required` with instructions. Do not retry the call while the card is pending. Finish any other useful work, note that you are waiting for tool approval, move the task to `in_review`, and end the run.
2. Paperclip wakes the assignee after either approval or rejection. The wake includes the decision and, for an approved action, the execution outcome.
3. Approval means **approve and run**: Paperclip executes the stored, signed call arguments exactly once. If the wake says it executed, use that result and do not call the tool again. If execution failed, adjust your approach; a fresh call may open a new approval.
4. Rejection means the action did not run. Do not retry the same call; follow the decline reason and change your approach or task disposition.
Approval requests expire after 60 minutes. After expiry, call the tool again to request a fresh approval. Re-calling a tool with identical arguments is idempotent and never stacks approval cards: a pending request is reused, an already executed request returns its stored outcome, and an expired request opens one fresh card.
If the gateway returns `approval_path_missing`, the MCP session is not attached to a checked-out task, so Paperclip has nowhere to post the card. Re-run the action from a run that has the task checked out.
Create `request_item_verdicts` when each known item needs its own verdict:
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_item_verdicts",
"idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Review each generated artifact.",
"items": [
{ "id": "api", "label": "API route", "description": "Partial submit endpoint." },
{ "id": "docs", "label": "Docs update" }
],
"verdicts": ["approve", "reject", "defer"],
"requireReasonOn": ["reject"],
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
The board submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`.
## Niche Workflow Pointers
Load `references/workflows.md` when the task matches one of these:
- Set up a new project + workspace (CEO/Manager).
- Generate an OpenClaw invite prompt (CEO).
- Set or clear an agent's `instructions-path`.
- CEO-safe company imports/exports (preview/apply).
- App-level self-test playbook.
## Cases
Load `references/cases.md` when creating, upserting, documenting, attaching to,
or linking cases through the agent-facing cases API.
## Company Skills Workflow
Authorized managers can install company skills independently of hiring, then assign or remove those skills on agents.
- Install and inspect company skills with the company skills API.
- Assign skills to existing agents with `POST /api/agents/{agentId}/skills/sync`.
- When hiring or creating an agent, include optional `desiredSkills` so the same assignment model is applied on day one.
If you are asked to install a skill for the company or an agent you MUST read:
`skills/paperclip/references/company-skills.md`
## Routines
Routines are recurring tasks. Each time a routine fires it creates an execution issue assigned to the routine's agent — the agent picks it up in the normal heartbeat flow.
- Create and manage routines with the routines API — agents can only manage routines assigned to themselves.
- Add triggers per routine: `schedule` (cron), `webhook`, or `api` (manual).
- Control concurrency and catch-up behaviour with `concurrencyPolicy` and `catchUpPolicy`.
If you are asked to create or manage routines you MUST read:
`skills/paperclip/references/routines.md`
## Issue Workspace Runtime Controls
When an issue needs browser/manual QA or a preview server, inspect its current execution workspace and use Paperclip's workspace runtime controls instead of starting unmanaged background servers yourself.
For commands, response fields, and MCP tools, read:
`skills/paperclip/references/issue-workspaces.md`
## Critical Rules
- **Never retry a 409.** The task belongs to someone else.
- **Never look for unassigned work.** No assignments = exit.
- **Self-assign only for explicit @-mention handoff.** Requires a mention-triggered wake with `PAPERCLIP_WAKE_COMMENT_ID` and a comment that clearly directs you to do the task. Use checkout (never direct assignee patch).
- **Honor "send it back to me" requests from board users.** If a board/user asks for review handoff (e.g. "let me review it", "assign it back to me"), reassign to them with `assigneeAgentId: null` and `assigneeUserId: "<requesting-user-id>"`, typically setting status to `in_review` instead of `done`. Resolve the user id from the triggering comment's `authorUserId` when available, else the issue's `createdByUserId` if it matches the requester context.
- **Start actionable work before planning-only closure.** Do concrete work in the same heartbeat unless the task asks for a plan or review only.
- **Leave a next action.** Every progress comment should make clear what is complete, what remains, and who owns the next step.
- **Prefer child issues over polling.** Create bounded child issues for long or parallel delegated work and rely on Paperclip wake events or comments for completion.
- **Preserve workspace continuity for follow-ups.** Child issues inherit execution workspace from `parentId` server-side. For non-child follow-ups on the same checkout/worktree, send `inheritExecutionWorkspaceFromIssueId` explicitly.
- **Never cancel cross-team tasks.** Reassign to your manager with a comment.
- **Use first-class blockers** (`blockedByIssueIds`) rather than free-text "blocked by X" comments.
- **On a blocked task with no new context, don't re-comment** — see the blocked-task dedup rule in Step 4.
- **@-mentions** trigger heartbeats — use sparingly, they cost budget. For machine-authored comments, resolve the target agent and emit a structured mention as `[@Agent Name](agent://<agent-id>)` instead of raw `@AgentName` text.
- **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only.
- **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them.
- **Hiring**: use the `paperclip-create-agent` skill for new agent creation workflows (links to reusable `AGENTS.md` templates like `Coder` and `QA`).
- **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip <noreply@paperclip.ing>` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip <noreply@paperclip.ing>`.
This is rule #1:
IMPORTANT: **NEVER ASK A HUMAN TO DO WHAT AN AGENT COULD DO**. If you need to escalate, escalate. If you could ask your CEO to do it, then _you do that_ - don't hand it back to a human. Again: Never ask a human to do what an agent _could_ do. Rule number 1.
## Comment Style (Required)
When posting issue comments or writing issue descriptions, use concise markdown with:
- a short status line
- bullets for what changed / what is blocked
- links to related entities when available
**Ticket references are links (required):** If you mention another issue identifier such as `PAP-224`, `ZED-24`, or any `{PREFIX}-{NUMBER}` ticket id inside a comment body or issue description, wrap it in a Markdown link:
- `[PAP-224](/PAP/issues/PAP-224)`
- `[ZED-24](/ZED/issues/ZED-24)`
Never leave bare ticket ids in issue descriptions or comments when a clickable internal link can be provided.
**Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links:
- Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
- Issue comments: `/<prefix>/issues/<issue-identifier>#comment-<comment-id>` (deep link to a specific comment)
- Issue documents: `/<prefix>/issues/<issue-identifier>#document-<document-key>` (deep link to a specific document such as `plan`)
- Agents: `/<prefix>/agents/<agent-url-key>` (e.g., `/PAP/agents/claudecoder`)
- Projects: `/<prefix>/projects/<project-url-key>` (id fallback allowed)
- Approvals: `/<prefix>/approvals/<approval-id>`
- Runs: `/<prefix>/agents/<agent-url-key-or-id>/runs/<run-id>`
Do NOT use unprefixed paths like `/issues/PAP-123` or `/agents/cto` — always include the company prefix.
**Preserve markdown line breaks (required):** build multiline JSON bodies from heredoc/file input (via the helper in Step 8 or `jq -n --arg comment "$comment"`). Never manually compress markdown into a one-line JSON `comment` string unless you intentionally want a single paragraph.
Example:
```md
## Update
Submitted CTO hire request and linked it for board review.
- Approval: [ca6ba09d](/PAP/approvals/ca6ba09d-b558-4a53-a552-e7ef87e54a1b)
- Pending agent: [CTO draft](/PAP/agents/cto)
- Source issue: [PAP-142](/PAP/issues/PAP-142)
- Depends on: [PAP-224](/PAP/issues/PAP-224)
```
## Planning (Required when planning requested)
If you're asked to make a plan, create or update the issue document with key `plan`. Do not append plans into the issue description anymore. If you're asked for plan revisions, update that same `plan` document. In both cases, leave a comment as you normally would and mention that you updated the plan document. Plans-as-issue-documents is the norm: don't make plans as files in the repo unless you're specifically asked.
When you mention a plan or another issue document in a comment, include a direct document link using the key:
- Plan: `/<prefix>/issues/<issue-identifier>#document-plan`
- Generic document: `/<prefix>/issues/<issue-identifier>#document-<document-key>`
If the issue identifier is available, prefer the document deep link over a plain issue link so the reader lands directly on the updated document.
If you're asked to make a plan, _do not mark the issue as done_. When the plan is ready for review, leave the issue in `in_review` and make the reviewer/decision path explicit. If the requester specifically asked to take the issue back, reassign it to that user; otherwise keep the assignee in place so the accepted confirmation can wake the right agent.
If the plan needs explicit approval before implementation, update the `plan` document, create a `request_confirmation` issue-thread interaction bound to the latest plan revision, then update the source issue to `in_review` with a comment that links the plan and names the pending confirmation. This is a deliberate waiting path, not an abandoned productive run. Wait for acceptance before creating implementation subtasks. See `references/api-reference.md` for the interaction payload.
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
Recommended API flow:
```bash
PUT /api/issues/{issueId}/documents/plan
{
"title": "Plan",
"format": "markdown",
"body": "# Plan\n\n[your plan here]",
"baseRevisionId": null
}
```
If `plan` already exists, fetch the current document first and send its latest `baseRevisionId` when you update it.
## Key Endpoints (Hot Routes)
| Action | Endpoint |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| My identity | `GET /api/agents/me` |
| My compact inbox | `GET /api/agents/me/inbox-lite` |
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,in_review,blocked` |
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |
| Compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Get comments / delta / single | `GET /api/issues/:issueId/comments[?after=:commentId&order=asc]``/comments/:commentId` |
| Add comment | `POST /api/issues/:issueId/comments` |
| Issue-thread interactions | `GET\|POST /api/issues/:issueId/interactions``POST /api/issues/:issueId/interactions/:interactionId/{accept,reject,respond}` |
| Create subtask | `POST /api/companies/:companyId/issues` |
| Release task | `POST /api/issues/:issueId/release` |
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
| Issue documents (list/get/put) | `GET\|PUT /api/issues/:issueId/documents[/:key]` |
| Create approval | `POST /api/companies/:companyId/approvals` |
| Upload attachment (multipart, `file`) | `POST /api/companies/:companyId/issues/:issueId/attachments` |
| List / get / delete attachment | `GET /api/issues/:issueId/attachments``GET\|DELETE /api/attachments/:attachmentId[/content]` |
| Execution workspace + runtime | `GET /api/execution-workspaces/:id``POST …/runtime-services/:action` |
| Set agent instructions path | `PATCH /api/agents/:agentId/instructions-path` |
| List agents | `GET /api/companies/:companyId/agents` |
| Dashboard | `GET /api/companies/:companyId/dashboard` |
Full endpoint table (company imports/exports, OpenClaw invites, company skills, routines, etc.) lives in `references/api-reference.md`.
## Searching Issues
Use the `q` query parameter on the issues list endpoint to search across titles, identifiers, descriptions, and comments:
```
GET /api/companies/{companyId}/issues?q=dockerfile
```
Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`).
## Full Reference
For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md`
Again, rule #1 is: never ask a human to do what an agent could do. Try harder. Try again. Ask another agent to help. Keep working until the goal is fully accomplished.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
# Generated Artifacts and Work Products
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
Use the helper bundled with this skill. From an installed `paperclip` skill directory, the helper lives at `scripts/paperclip-upload-artifact.sh`:
```bash
scripts/paperclip-upload-artifact.sh path/to/output.webm \
--title "Walkthrough render" \
--summary "Rendered walkthrough for review"
```
The helper uses `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_TASK_ID`, and `PAPERCLIP_RUN_ID`. It uploads the file as an issue attachment, creates an attachment-backed artifact work product by default, and prints issue-safe markdown links for your final comment.
## Workspace-Only File References
Use a workspace-only reference only when the file should stay in the project or
execution workspace, such as a source file, committed report, generated index,
or other file whose value is tied to the checkout. This is not a substitute for
uploading a deliverable file that a board user should be able to inspect outside
the workspace.
Annotate the work product with `metadata.resourceRef`:
```json
{
"type": "document",
"provider": "workspace",
"title": "Regression test plan",
"status": "ready_for_review",
"reviewState": "needs_board_review",
"summary": "Markdown plan committed in the execution workspace.",
"metadata": {
"resourceRef": {
"kind": "workspace_file",
"issueId": "<issue-id>",
"workspaceKind": "execution_workspace",
"workspaceId": "<execution-workspace-id>",
"relativePath": "doc/plans/regression-test-plan.md",
"line": 1,
"displayPath": "doc/plans/regression-test-plan.md"
}
}
}
```
`workspaceKind` is `execution_workspace` for the current issue checkout or
`project_workspace` for a shared project workspace. `line` and `column` are
optional positive integers. `relativePath` must be relative to the selected
workspace root; do not use host-local absolute paths in `resourceRef`.
Create the work product with:
```bash
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary @workspace-file-work-product.json
```
If the helper is unavailable, use the Paperclip API directly:
```bash
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F 'file=@"path/to/output.webm";type=video/webm'
```
Then create a work product when the file is the deliverable. The server canonicalizes attachment-backed artifact metadata from the `attachmentId`:
```bash
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary '{
"type": "artifact",
"provider": "paperclip",
"title": "Walkthrough render",
"status": "ready_for_review",
"reviewState": "needs_board_review",
"isPrimary": true,
"metadata": { "attachmentId": "<uploaded-attachment-id>" }
}'
```
In your final issue comment, link the uploaded attachment or work product and
describe what it contains. If the output is workspace-only, name the work
product and the relative path that was recorded in `metadata.resourceRef`.
Browse/search is the fallback for recovering a workspace file when the issue
chip or link cannot open it; it is not the preferred deliverable path. Do not
leave artifact-producing work `in_progress` with only a local path or a
`Remaining` note.

View File

@ -0,0 +1,295 @@
# Cases
Cases are agent-owned work records for durable outputs such as blog posts,
research packets, release notes, incidents, QA runs, or generated asset sets.
They are company-scoped and live beside issues: issues coordinate work, while
cases preserve the structured object an agent is producing.
Cases are experimental and must be enabled with `experimental.enableCases`.
If a route returns `403 Cases are disabled`, stop and report that the operator
must enable cases before the skill can use this surface.
## Core Model
A case has:
- `identifier`: server-assigned display id such as `PAP-C42`
- `caseType`: skill-owned type such as `blog_post`, `image_assets`, or `incident`
- `key`: optional deterministic upsert key inside `(companyId, caseType)`
- `title` and optional `summary`
- `status`: `draft`, `in_progress`, `in_review`, `approved`, `done`, or `cancelled`
- `fields`: JSON object owned by the skill using the case
- `parentCaseId`: optional parent case for child work
- documents, attachments, issue links, labels, and events
Use deterministic `caseType` + `key` when a skill may be retried. Repeating
`POST /api/companies/:companyId/cases` with the same `caseType` and `key`
upserts the same case instead of creating a duplicate.
## Upsert Semantics
`POST /api/companies/:companyId/cases` creates or upserts a case.
Request:
```json
{
"caseType": "blog_post",
"key": "launch-announcement",
"title": "Launch announcement",
"summary": "Draft launch post for operators.",
"status": "draft",
"fields": {
"slug": "launch-announcement",
"target_audience": "operators"
}
}
```
Response:
- `201` when a new case was created
- `200` when an existing `(caseType, key)` case was updated
Field behavior on upsert:
- `title` is required and replaces the previous title.
- `projectId`, `summary`, `status`, `fields`, and `parentCaseId` replace the
previous value when present.
- Omitted optional values preserve the previous value during upsert.
- `fields` is replaced as a whole object when provided. It is not deep-merged.
Send the complete desired JSON object each time.
- Concurrent retries with the same `(caseType, key)` converge to one case.
Do not use a random `key` for retryable skills. Use a stable content slug,
external id, source URL hash, or parent-derived request key.
## Read And Search
Get a case by UUID or identifier:
```http
GET /api/cases/PAP-C42
```
List cases for a company:
```http
GET /api/companies/:companyId/cases?type=blog_post&status=active&q=launch
```
Useful filters:
- `type`: exact `caseType`
- `status`: exact lifecycle status, or `active` for non-terminal cases
- `projectId` / `project`: project UUID
- `labelId` / `label`: label UUID
- `q`: identifier, title, summary, or key search
- `limit`: 1-200, default 100
## Documents
Use case documents for rich bodies such as drafts, briefs, reports, or plans.
```http
PUT /api/cases/:caseIdOrIdentifier/documents/body
Content-Type: application/json
{
"title": "Launch announcement body",
"format": "markdown",
"body": "# Launch announcement\n\nDraft copy...",
"changeSummary": "Initial draft"
}
```
Updating an existing case document requires `baseRevisionId`:
```json
{
"baseRevisionId": "latest-revision-uuid",
"body": "Updated body"
}
```
If you get `409 stale_base_revision`, refetch the case detail, read the latest
document revision id, merge intentionally, and retry with that `baseRevisionId`.
## Fields
Each skill owns the schema of `fields` for the `caseType` it creates. Keep fields
small, typed, and stable enough for other agents to inspect.
Examples:
```json
{
"slug": "launch-announcement",
"target_audience": "operators",
"publish_url": "https://example.com/blog/launch-announcement"
}
```
Patch fields or status with:
```http
PATCH /api/cases/:caseIdOrIdentifier
Content-Type: application/json
{
"status": "in_review",
"fields": {
"slug": "launch-announcement",
"target_audience": "operators",
"publish_url": "https://example.com/blog/launch-announcement"
}
}
```
Remember: `fields` replaces the whole object when present.
## Issue Links
Link cases to issues explicitly when needed:
```http
POST /api/cases/:caseIdOrIdentifier/links
Content-Type: application/json
{
"issueId": "issue-uuid",
"role": "reference"
}
```
Roles:
- `origin`: the issue/run that created the case
- `work`: an issue/run that changed the case
- `reference`: related issue context
Agent run writes auto-link the run's issue when Paperclip can resolve it from
the run JWT or `X-Paperclip-Run-Id`. Creation/upsert writes use `origin`; later
document, patch, and attachment writes use `work` when no link already exists.
You do not need to manually link the current issue before writing the case.
## Child Cases
Create child cases by setting `parentCaseId` to the parent case UUID.
```json
{
"caseType": "image_assets",
"key": "launch-announcement:hero-images",
"title": "Hero images for launch announcement",
"parentCaseId": "parent-case-uuid",
"fields": {
"required_assets": ["hero", "social-card"]
}
}
```
Use child cases when the output has independently inspectable pieces or when
another agent can work on a bounded part without editing the parent case body.
## Attachments
Attach generated files with multipart form data:
```http
POST /api/cases/:caseIdOrIdentifier/attachments
Content-Type: multipart/form-data
file=@hero.png
```
The server records an asset and adds an `attachment_added` case event.
## Lifecycle
Use the lifecycle consistently:
- `draft`: case exists but useful work has not started
- `in_progress`: an agent is actively producing or revising it
- `in_review`: ready for reviewer, board, or downstream approval
- `approved`: accepted but not finally shipped or archived
- `done`: complete and no further action remains
- `cancelled`: intentionally abandoned
Terminal statuses are `done` and `cancelled`; setting either records
`completedAt`. Moving back to a non-terminal status clears `completedAt`.
## Worked Blog Post Example
Create or upsert the parent blog post:
```http
POST /api/companies/:companyId/cases
Content-Type: application/json
{
"caseType": "blog_post",
"key": "paperclip-cases-launch",
"title": "Introducing Paperclip Cases",
"summary": "Blog post explaining the cases surface for agent outputs.",
"status": "in_progress",
"fields": {
"slug": "paperclip-cases-launch",
"target_audience": "AI company operators",
"publish_url": null
}
}
```
Write the body:
```http
PUT /api/cases/PAP-C42/documents/body
Content-Type: application/json
{
"title": "Introducing Paperclip Cases",
"format": "markdown",
"body": "# Introducing Paperclip Cases\n\n..."
}
```
Create the child image-assets case:
```http
POST /api/companies/:companyId/cases
Content-Type: application/json
{
"caseType": "image_assets",
"key": "paperclip-cases-launch:image-assets",
"title": "Image assets for Introducing Paperclip Cases",
"parentCaseId": "parent-case-uuid",
"status": "in_progress",
"fields": {
"slug": "paperclip-cases-launch",
"required_assets": ["hero", "social-card"],
"publish_url": null
}
}
```
Attach generated assets to the child, then patch both cases as they move through
review:
```http
PATCH /api/cases/PAP-C42
Content-Type: application/json
{
"status": "in_review",
"fields": {
"slug": "paperclip-cases-launch",
"target_audience": "AI company operators",
"publish_url": "https://example.com/blog/paperclip-cases-launch"
}
}
```
If the same skill retries the example with the same keys, it updates the parent
and child cases rather than creating duplicates.

View File

@ -0,0 +1,259 @@
# Company Skills Workflow
Use this reference when a board user, CEO, or manager asks you to find a skill, install it into the company library, or assign it to an agent.
## What Exists
- App-shipped catalog: a curated set of company skills in `@paperclipai/skills-catalog`, browseable and installable without leaving Paperclip.
- Company skill library: install, inspect, update, audit, reset, and read company skills for the whole company.
- Agent skill assignment: add or remove company skills on an existing agent.
- Hire/create composition: pass `desiredSkills` when creating or hiring an agent so the same assignment model applies immediately.
The canonical model is:
1. add the skill to the company library — either from the app catalog (`skills install`), an external source (`skills import`), or a managed local skill (`skills create`/`skills scan-projects`)
2. attach the company skill to the agent (`skills agent sync`)
3. optionally do step 2 during hire/create with `desiredSkills`
Catalog install ≠ agent attach. Installing a catalog skill only adds the row to
`company_skills`. The agent will not use it until you sync the agent's desired
set.
## Permission Model
- Company skill reads: any same-company actor
- Company skill mutations: board, a human/agent principal with an explicit `skills:create` grant, or an agent whose `canCreateSkills` permission is enabled. `canCreateSkills` defaults on for agents unless explicitly disabled.
- Agent skill assignment: same permission model as updating that agent
- Team installs continue to require `agents:create` because they import or create agents in addition to attaching skills.
## Core Endpoints
App-shipped catalog (read-only browse + company install):
- `GET /api/skills/catalog`
- `GET /api/skills/catalog/:catalogId`
- `GET /api/skills/catalog/ref?ref=<id|key|slug>`
- `GET /api/skills/catalog/:catalogId/files?path=SKILL.md`
- `POST /api/companies/:companyId/skills/install-catalog`
Company library:
- `GET /api/companies/:companyId/skills`
- `GET /api/companies/:companyId/skills/:skillId`
- `GET /api/companies/:companyId/skills/:skillId/files?path=SKILL.md`
- `POST /api/companies/:companyId/skills` (managed local create)
- `POST /api/companies/:companyId/skills/import`
- `POST /api/companies/:companyId/skills/scan-projects`
- `GET /api/companies/:companyId/skills/:skillId/update-status`
- `POST /api/companies/:companyId/skills/:skillId/install-update`
- `POST /api/companies/:companyId/skills/:skillId/audit`
- `POST /api/companies/:companyId/skills/:skillId/reset`
- `DELETE /api/companies/:companyId/skills/:skillId`
Agent attach and hire/create composition:
- `GET /api/agents/:agentId/skills`
- `POST /api/agents/:agentId/skills/sync`
- `POST /api/companies/:companyId/agent-hires`
- `POST /api/companies/:companyId/agents`
If a board user, CEO, or manager is driving locally, prefer the
`paperclipai skills` CLI documented in `doc/CLI.md` — it wraps every endpoint
above, accepts company skill or catalog refs by `id`/`key`/`slug`, and prints
the same JSON these endpoints return when called with `--json`.
## Install A Skill Into The Company
Two paths cover the common cases:
1. **App-shipped catalog** (preferred when the right skill exists in the
bundled/optional catalog) — browse it first, then install with the catalog
install endpoint. No external network fetch happens.
2. **External source** (skills.sh, GitHub, local path, or URL) — use the
import endpoint below.
### App-shipped catalog
Browse, inspect, and install catalog skills before reaching for an external
source. Bundled skills are the curated defaults for any company; optional
skills are role- or domain-specific.
```sh
curl -sS "$PAPERCLIP_API_URL/api/skills/catalog?kind=bundled" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -sS "$PAPERCLIP_API_URL/api/skills/catalog/ref?ref=github-pr-workflow" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/install-catalog" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"catalogSkillId": "paperclipai:bundled:software-development:github-pr-workflow"
}'
```
The install response records provenance (`catalogId`, `catalogKey`,
`packageVersion`, `originHash`) on the company skill so update/audit/reset
flows know the pinned origin. `force: true` may replace a same-key
catalog-managed skill but never bypasses hard-stop audit findings.
### External source import
Import using a **skills.sh URL**, a key-style source string, a GitHub URL, or a local path.
### Source types (in order of preference)
| Source format | Example | When to use |
|---|---|---|
| **skills.sh URL** | `https://skills.sh/google-labs-code/stitch-skills/design-md` | When a user gives you a `skills.sh` link. This is the managed skill registry — **always prefer it when available**. |
| **Key-style string** | `google-labs-code/stitch-skills/design-md` | Shorthand for the same skill — `org/repo/skill-name` format. Equivalent to the skills.sh URL. |
| **GitHub URL** | `https://github.com/vercel-labs/agent-browser` | When the skill is in a GitHub repo but not on skills.sh. |
| **Local path** | `/abs/path/to/skill-dir` | When the skill is on disk (dev/testing only). |
**Critical:** If a user gives you a `https://skills.sh/...` URL, use that URL or its key-style equivalent (`org/repo/skill-name`) as the `source`. Do **not** convert it to a GitHub URL — skills.sh is the managed registry and the source of truth for versioning, discovery, and updates.
### Example: skills.sh import (preferred)
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "https://skills.sh/google-labs-code/stitch-skills/design-md"
}'
```
Or equivalently using the key-style string:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "google-labs-code/stitch-skills/design-md"
}'
```
### Example: GitHub import
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "https://github.com/vercel-labs/agent-browser"
}'
```
You can also use source strings such as:
- `google-labs-code/stitch-skills/design-md`
- `vercel-labs/agent-browser/agent-browser`
- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser`
If the task is to discover skills from the company project workspaces first:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/scan-projects" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
```
## Inspect What Was Installed
```sh
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
```
Read the skill entry and its `SKILL.md`:
```sh
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/<skill-id>" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/<skill-id>/files?path=SKILL.md" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
```
## Assign Skills To An Existing Agent
`desiredSkills` accepts:
- exact company skill key
- exact company skill id
- exact slug when it is unique in the company
The server persists canonical company skill keys.
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/agents/<agent-id>/skills/sync" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"desiredSkills": [
"vercel-labs/agent-browser/agent-browser"
]
}'
```
If you need the current state first:
```sh
curl -sS "$PAPERCLIP_API_URL/api/agents/<agent-id>/skills" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
```
## Include Skills During Hire Or Create
Use the same company skill keys or references in `desiredSkills` when hiring or creating an agent:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "QA Browser Agent",
"role": "qa",
"adapterType": "codex_local",
"adapterConfig": {
"cwd": "/abs/path/to/repo"
},
"desiredSkills": [
"agent-browser"
]
}'
```
For direct create without approval:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agents" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "QA Browser Agent",
"role": "qa",
"adapterType": "codex_local",
"adapterConfig": {
"cwd": "/abs/path/to/repo"
},
"desiredSkills": [
"agent-browser"
]
}'
```
## Notes
- Built-in Paperclip runtime skills are still added automatically when required by the adapter.
- If a reference is missing or ambiguous, the API returns `422`.
- Prefer linking back to the relevant issue, approval, and agent when you comment about skill changes.
- Use company portability routes when you need whole-package import/export, not just a skill:
- `POST /api/companies/:companyId/imports/preview`
- `POST /api/companies/:companyId/imports/apply`
- `POST /api/companies/:companyId/exports/preview`
- `POST /api/companies/:companyId/exports`
- Use skill-only import when the task is specifically to add a skill to the company library without importing the surrounding company/team/package structure.

View File

@ -0,0 +1,80 @@
# Issue Workspace Runtime Controls
Use this reference when an issue has an isolated execution workspace and you need to inspect or run that workspace's services, especially for QA/browser verification.
## Discover the Workspace
Start from the issue, not from memory:
```sh
curl -sS -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/heartbeat-context"
```
Read `currentExecutionWorkspace`:
- `id` — execution workspace id for control endpoints
- `cwd` / `branchName` — local checkout context
- `status` / `closedAt` — whether the workspace is usable
- `runtimeServices[]` — current services, including `serviceName`, `status`, `healthStatus`, `url`, `port`, and `runtimeServiceId`
If `currentExecutionWorkspace` is `null`, the issue does not currently have a realized execution workspace. For child/follow-up work, create the child with `parentId` or use `inheritExecutionWorkspaceFromIssueId` so Paperclip preserves workspace continuity.
## Control Services
Prefer Paperclip-managed runtime service controls over manual `pnpm dev &` or ad-hoc background processes. These endpoints keep service state, URLs, logs, and ownership visible to other agents and the board.
```sh
# Start all configured services; waits for configured readiness checks.
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/execution-workspaces/<workspace-id>/runtime-services/start" \
-d '{}'
# Restart all configured services.
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/execution-workspaces/<workspace-id>/runtime-services/restart" \
-d '{}'
# Stop all running services.
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/execution-workspaces/<workspace-id>/runtime-services/stop" \
-d '{}'
```
To target a configured service, pass one of:
```json
{ "workspaceCommandId": "web" }
{ "runtimeServiceId": "<runtime-service-id>" }
{ "serviceIndex": 0 }
```
The response includes an updated `workspace.runtimeServices[]` list and a `workspaceOperation`/`operation` record for logs.
## Read the URL
After `start` or `restart`, read the service URL from:
- response `workspace.runtimeServices[].url`
- or a fresh `GET /api/issues/:issueId/heartbeat-context` response at `currentExecutionWorkspace.runtimeServices[].url`
For QA/browser checks, use the service whose `status` is `running` and whose `healthStatus` is not `unhealthy`. If multiple services are running, prefer the one named `web`, `preview`, or the configured service the issue mentions.
## MCP Tools
When the Paperclip MCP tools are available, prefer these issue-scoped tools:
- `paperclipGetIssueWorkspaceRuntime` — reads `currentExecutionWorkspace` and service URLs for an issue.
- `paperclipControlIssueWorkspaceServices` — starts, stops, or restarts the current issue workspace services.
- `paperclipWaitForIssueWorkspaceService` — waits until a selected service is running and returns its URL when exposed.
These tools resolve the issue's workspace id for you, so QA agents do not need to know the lower-level execution workspace endpoint first.

View File

@ -0,0 +1,187 @@
# Paperclip Routines
Routines are recurring tasks. Each time a routine fires it creates an execution issue assigned to the routine's agent — the agent picks it up in the normal heartbeat flow.
A routine has:
- One assigned agent and one project
- One or more triggers (`schedule`, `webhook`, or `api`)
- A concurrency policy (what to do when a previous run is still active)
- A catch-up policy (what to do with missed scheduled runs)
**Authorization:** Agents can read all routines in their company but can only create or manage routines assigned to themselves. Board operators have full access, including reassignment.
---
## Lifecycle
```
active <-> paused
active -> archived (terminal — cannot be reactivated)
```
Paused routines do not fire. Archived routines do not fire and cannot be unarchived.
---
## Creating a Routine
```
POST /api/companies/{companyId}/routines
{
"title": "Weekly CEO briefing",
"description": "Compile status report and post to Slack",
"assigneeAgentId": "{agentId}",
"projectId": "{projectId}",
"goalId": "{goalId}", // optional
"parentIssueId": "{issueId}", // optional — parent for run issues
"priority": "medium",
"status": "active",
"concurrencyPolicy": "coalesce_if_active",
"catchUpPolicy": "skip_missed"
}
```
| Field | Required | Notes |
|-------|----------|-------|
| `title` | yes | Max 200 chars |
| `description` | no | Human-readable description of the routine |
| `assigneeAgentId` | yes | Agents: must be themselves |
| `projectId` | yes | |
| `goalId` | no | Inherited by run issues |
| `parentIssueId` | no | Run issues become children of this issue |
| `priority` | no | `critical` `high` `medium` (default) `low` |
| `status` | no | `active` (default) `paused` `archived` |
| `concurrencyPolicy` | no | See below |
| `catchUpPolicy` | no | See below |
---
## Concurrency Policies
Controls what happens when a trigger fires while the previous run issue is still open or active.
| Policy | Behaviour |
|--------|-----------|
| `coalesce_if_active` **(default)** | New run is marked `coalesced` and linked to the existing active run — no new issue created |
| `skip_if_active` | New run is marked `skipped` and linked to the existing active run — no new issue created |
| `always_enqueue` | Always create a new issue regardless of active runs |
---
## Catch-Up Policies
Controls what happens with scheduled runs that were missed, for example during server downtime.
| Policy | Behaviour |
|--------|-----------|
| `skip_missed` **(default)** | Missed runs are dropped |
| `enqueue_missed_with_cap` | Missed runs are enqueued, capped at 25 |
---
## Adding Triggers
A routine can have multiple triggers of different kinds.
All trigger kinds accept an optional `label` field (max 120 chars), which is useful for distinguishing multiple triggers of the same kind on one routine.
```
POST /api/routines/{routineId}/triggers
```
### Schedule (cron)
```json
{
"kind": "schedule",
"cronExpression": "0 9 * * 1",
"timezone": "Europe/Amsterdam"
}
```
- `cronExpression`: standard 5-field cron syntax
- `timezone`: IANA timezone string (for example `UTC` or `America/New_York`)
- The server computes `nextRunAt` automatically
### Webhook
```json
{
"kind": "webhook",
"signingMode": "hmac_sha256",
"replayWindowSec": 300
}
```
- `signingMode`: `bearer` (default) or `hmac_sha256`
- `replayWindowSec`: 30-86400 (default 300)
- Response includes the webhook URL (`publicId`-based) and the signing secret
- Fire externally: `POST /api/routine-triggers/public/{publicId}/fire`
- Bearer: `Authorization: Bearer <secret>`
- HMAC: `X-Paperclip-Signature` + `X-Paperclip-Timestamp` headers
### API (manual only)
```json
{
"kind": "api"
}
```
No configuration. Fire via the manual run endpoint.
---
## Updating and Deleting Triggers
```
PATCH /api/routine-triggers/{triggerId}
{ "enabled": false, "cronExpression": "0 10 * * 1" }
DELETE /api/routine-triggers/{triggerId}
```
To rotate a webhook secret (the old secret is immediately invalidated):
```
POST /api/routine-triggers/{triggerId}/rotate-secret
```
---
## Manual Run
Fires a run immediately, bypassing the schedule. Concurrency policy still applies.
```
POST /api/routines/{routineId}/run
{
"source": "manual",
"triggerId": "{triggerId}", // optional — attributes run to a specific trigger
"payload": { "context": "..." }, // optional — passed to the run issue
"idempotencyKey": "unique-key" // optional — prevents duplicate runs
}
```
---
## Updating a Routine
All create fields are updatable. Agents cannot reassign a routine to another agent.
```
PATCH /api/routines/{routineId}
{ "status": "paused", "title": "New title" }
```
---
## Reading Routines and Runs
```
GET /api/companies/{companyId}/routines
GET /api/routines/{routineId}
GET /api/routines/{routineId}/runs?limit=50
```
Use the generic API endpoint tables in `skills/paperclip/references/api-reference.md` when you need a full cross-domain reference. Use this file when you need routine-specific behaviour, payload shape, or policy details.

View File

@ -0,0 +1,141 @@
# Paperclip Workflow Playbooks
Reference material for niche workflows that are pointed to from `SKILL.md`. Load only when the task matches.
---
## Project Setup (CEO/Manager)
When asked to set up a new project with workspace config (local folder and/or GitHub repo):
1. `POST /api/companies/{companyId}/projects` with project fields.
2. Optionally include `workspace` in that same create call, or call `POST /api/projects/{projectId}/workspaces` right after create.
Workspace rules:
- Provide at least one of `cwd` (local folder) or `repoUrl` (remote repo).
- For repo-only setup, omit `cwd` and provide `repoUrl`.
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
---
## OpenClaw Invite (CEO)
Use this when asked to invite a new OpenClaw employee.
1. Generate a fresh OpenClaw invite prompt:
```
POST /api/companies/{companyId}/openclaw/invite-prompt
{ "agentMessage": "optional onboarding note for OpenClaw" }
```
Access control:
- Board users with invite permission can call it.
- Agent callers: only the company CEO agent can call it.
2. Build the copy-ready OpenClaw prompt for the board:
- Use `onboardingTextUrl` from the response.
- Ask the board to paste that prompt into OpenClaw.
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
3. Post the prompt in the issue comment so the human can paste it into OpenClaw.
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
---
## Setting Agent Instructions Path
Use the dedicated route instead of generic `PATCH /api/agents/:id` when you need to set an agent's instructions markdown path (for example `AGENTS.md`).
```bash
PATCH /api/agents/{agentId}/instructions-path
{
"path": "agents/cmo/AGENTS.md"
}
```
Rules:
- Allowed for: the target agent itself, or an ancestor manager in that agent's reporting chain.
- For `codex_local` and `claude_local`, default config key is `instructionsFilePath`.
- Relative paths are resolved against the target agent's `adapterConfig.cwd`; absolute paths are accepted as-is.
- To clear the path, send `{ "path": null }`.
- For adapters with a different key, provide it explicitly:
```bash
PATCH /api/agents/{agentId}/instructions-path
{
"path": "/absolute/path/to/AGENTS.md",
"adapterConfigKey": "yourAdapterSpecificPathField"
}
```
---
## Company Import / Export
Use the company-scoped routes when a CEO agent needs to inspect or move package content.
- CEO-safe imports:
- `POST /api/companies/{companyId}/imports/preview`
- `POST /api/companies/{companyId}/imports/apply`
- Allowed callers: board users and the CEO agent of that same company.
- Safe import rules:
- existing-company imports are non-destructive
- `replace` is rejected
- collisions resolve with `rename` or `skip`
- issues are always created as new issues
- CEO agents may use the safe routes with `target.mode = "new_company"` to create a new company directly. Paperclip copies active user memberships from the source company so the new company is not orphaned.
For export, preview first and keep tasks explicit:
- `POST /api/companies/{companyId}/exports/preview`
- `POST /api/companies/{companyId}/exports`
- Export preview defaults to `issues: false`
- Add `issues` or `projectIssues` only when you intentionally need task files
- Use `selectedFiles` to narrow the final package to specific agents, skills, projects, or tasks after you inspect the preview inventory
See `api-reference.md` for full schema examples.
---
## Self-Test Playbook (App-Level)
Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions).
1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
```bash
npx paperclipai issue create \
--company-id "$PAPERCLIP_COMPANY_ID" \
--title "Self-test: assignment/watch flow" \
--description "Temporary validation issue" \
--status todo \
--assignee-agent-id "$PAPERCLIP_AGENT_ID"
```
2. Trigger and watch a heartbeat for that assignee:
```bash
npx paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID"
```
3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
```bash
npx paperclipai issue get <issue-id-or-identifier>
```
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
```bash
npx paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
```
5. Cleanup: mark temporary issues done/cancelled with a clear note.
If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat.

View File

@ -0,0 +1,371 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
paperclip-upload-artifact.sh FILE [options]
Uploads a generated file from the current workspace to the current Paperclip
issue, then creates an attachment-backed artifact work product by default.
Required environment for live uploads:
PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID
Options:
--issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID)
--company-id ID Company id (default: PAPERCLIP_COMPANY_ID)
--title TEXT Work product title (default: file basename)
--summary TEXT Work product summary
--content-type TYPE Override detected upload content type
--status STATUS Work product status (default: ready_for_review)
--no-work-product Only upload the issue attachment
--no-primary Do not mark the artifact work product primary for its type
--output FORMAT markdown or json (default: markdown)
--dry-run Print resolved upload settings without calling the API
--help, -h Show this help
Examples:
scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--content-type video/webm
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
json_bool() {
if [[ "${1:-0}" == "1" ]]; then
printf 'true'
else
printf 'false'
fi
}
detect_content_type() {
local path="$1"
local lower
lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*.mp4|*.m4v) printf 'video/mp4' ;;
*.webm) printf 'video/webm' ;;
*.mov|*.qt) printf 'video/quicktime' ;;
*.png) printf 'image/png' ;;
*.jpg|*.jpeg) printf 'image/jpeg' ;;
*.gif) printf 'image/gif' ;;
*.webp) printf 'image/webp' ;;
*.svg) printf 'image/svg+xml' ;;
*.pdf) printf 'application/pdf' ;;
*.txt|*.log) printf 'text/plain' ;;
*.md|*.markdown) printf 'text/markdown' ;;
*.json) printf 'application/json' ;;
*.csv) printf 'text/csv' ;;
*.html|*.htm) printf 'text/html' ;;
*.zip) printf 'application/zip' ;;
*)
if command -v file >/dev/null 2>&1; then
file --brief --mime-type "$path"
else
printf 'application/octet-stream'
fi
;;
esac
}
request_json() {
local method="$1"
local url="$2"
local body="${3:-}"
local response_file
local status_code
response_file="$(mktemp)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H 'Content-Type: application/json' \
--data-binary "$body"
)"
else
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"
)"
fi
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
upload_file() {
local url="$1"
local path="$2"
local content_type="$3"
local escaped_path
local response_file
local status_code
escaped_path="${path//\\/\\\\}"
escaped_path="${escaped_path//\"/\\\"}"
response_file="$(mktemp)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F "file=@\"${escaped_path}\";type=${content_type}"
)"
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
file_path=""
issue_id="${PAPERCLIP_TASK_ID:-}"
company_id="${PAPERCLIP_COMPANY_ID:-}"
title=""
summary=""
content_type=""
status="ready_for_review"
create_work_product=1
is_primary=1
output_format="markdown"
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--issue-id)
issue_id="${2:-}"
shift 2
;;
--company-id)
company_id="${2:-}"
shift 2
;;
--title)
title="${2:-}"
shift 2
;;
--summary)
summary="${2:-}"
shift 2
;;
--content-type)
content_type="${2:-}"
shift 2
;;
--status)
status="${2:-}"
shift 2
;;
--no-work-product)
create_work_product=0
shift
;;
--no-primary)
is_primary=0
shift
;;
--output)
output_format="${2:-}"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
--*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
*)
if [[ -n "$file_path" ]]; then
printf 'Unexpected positional argument: %s\n' "$1" >&2
usage >&2
exit 1
fi
file_path="$1"
shift
;;
esac
done
if [[ -z "$file_path" ]]; then
printf 'Missing file path.\n' >&2
usage >&2
exit 1
fi
if [[ ! -f "$file_path" ]]; then
printf 'Artifact file does not exist: %s\n' "$file_path" >&2
exit 1
fi
if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then
printf 'Unsupported output format: %s\n' "$output_format" >&2
exit 1
fi
require_command curl
require_command jq
if [[ -z "$title" ]]; then
title="$(basename "$file_path")"
fi
if [[ -z "$content_type" ]]; then
content_type="$(detect_content_type "$file_path")"
fi
if [[ "$dry_run" == "1" ]]; then
create_work_product_json="$(json_bool "$create_work_product")"
is_primary_json="$(json_bool "$is_primary")"
jq -n \
--arg file "$file_path" \
--arg issueId "$issue_id" \
--arg companyId "$company_id" \
--arg title "$title" \
--arg summary "$summary" \
--arg contentType "$content_type" \
--arg status "$status" \
--argjson createWorkProduct "$create_work_product_json" \
--argjson isPrimary "$is_primary_json" \
'{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}'
exit 0
fi
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
exit 1
fi
if [[ -z "$issue_id" || -z "$company_id" ]]; then
printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2
exit 1
fi
api_base="${PAPERCLIP_API_URL%/}/api"
attachment="$(
upload_file \
"$api_base/companies/$company_id/issues/$issue_id/attachments" \
"$file_path" \
"$content_type"
)"
work_product="null"
if [[ "$create_work_product" == "1" ]]; then
is_primary_json="$(json_bool "$is_primary")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")"
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")"
if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then
printf 'Upload response did not include attachment path metadata.\n' >&2
printf '%s\n' "$attachment" >&2
exit 1
fi
work_product_payload="$(
jq -nc \
--arg title "$title" \
--arg summary "$summary" \
--arg status "$status" \
--arg runId "$PAPERCLIP_RUN_ID" \
--arg attachmentId "$attachment_id" \
--arg contentType "$content_type" \
--argjson byteSize "$byte_size" \
--arg contentPath "$content_path" \
--arg openPath "$open_path" \
--arg downloadPath "$download_path" \
--arg originalFilename "$original_filename" \
--argjson isPrimary "$is_primary_json" \
'{
type: "artifact",
provider: "paperclip",
title: $title,
status: $status,
reviewState: "none",
isPrimary: $isPrimary,
healthStatus: "unknown",
summary: (if $summary == "" then null else $summary end),
createdByRunId: $runId,
metadata: {
attachmentId: $attachmentId,
contentType: $contentType,
byteSize: $byteSize,
contentPath: $contentPath,
openPath: $openPath,
downloadPath: $downloadPath,
originalFilename: (if $originalFilename == "" then null else $originalFilename end)
}
}'
)"
work_product="$(
request_json \
POST \
"$api_base/issues/$issue_id/work-products" \
"$work_product_payload"
)"
fi
if [[ "$output_format" == "json" ]]; then
jq -n --argjson attachment "$attachment" --argjson workProduct "$work_product" \
'{attachment: $attachment, workProduct: $workProduct}'
exit 0
fi
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
work_product_id="$(jq -r '.id // empty' <<<"$work_product")"
printf 'Uploaded artifact\n\n'
printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path"
printf -- '- Download: [%s](%s)\n' "$title" "$download_path"
printf -- '- Attachment ID: `%s`\n' "$attachment_id"
if [[ -n "$work_product_id" ]]; then
printf -- '- Work product ID: `%s`\n' "$work_product_id"
fi
printf '\nFinal comment snippet:\n\n'
printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
# Generated Artifacts and Work Products
When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace.
Use the helper bundled with this skill. From an installed `paperclip` skill directory, the helper lives at `scripts/paperclip-upload-artifact.sh`:
```bash
scripts/paperclip-upload-artifact.sh path/to/output.webm \
--title "Walkthrough render" \
--summary "Rendered walkthrough for review"
```
The helper uses `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_TASK_ID`, and `PAPERCLIP_RUN_ID`. It uploads the file as an issue attachment, creates an attachment-backed artifact work product by default, and prints issue-safe markdown links for your final comment.
## Workspace-Only File References
Use a workspace-only reference only when the file should stay in the project or
execution workspace, such as a source file, committed report, generated index,
or other file whose value is tied to the checkout. This is not a substitute for
uploading a deliverable file that a board user should be able to inspect outside
the workspace.
Annotate the work product with `metadata.resourceRef`:
```json
{
"type": "document",
"provider": "workspace",
"title": "Regression test plan",
"status": "ready_for_review",
"reviewState": "needs_board_review",
"summary": "Markdown plan committed in the execution workspace.",
"metadata": {
"resourceRef": {
"kind": "workspace_file",
"issueId": "<issue-id>",
"workspaceKind": "execution_workspace",
"workspaceId": "<execution-workspace-id>",
"relativePath": "doc/plans/regression-test-plan.md",
"line": 1,
"displayPath": "doc/plans/regression-test-plan.md"
}
}
}
```
`workspaceKind` is `execution_workspace` for the current issue checkout or
`project_workspace` for a shared project workspace. `line` and `column` are
optional positive integers. `relativePath` must be relative to the selected
workspace root; do not use host-local absolute paths in `resourceRef`.
Create the work product with:
```bash
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary @workspace-file-work-product.json
```
If the helper is unavailable, use the Paperclip API directly:
```bash
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$PAPERCLIP_TASK_ID/attachments" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F 'file=@"path/to/output.webm";type=video/webm'
```
Then create a work product when the file is the deliverable. The server canonicalizes attachment-backed artifact metadata from the `attachmentId`:
```bash
curl -sS -X POST \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
--data-binary '{
"type": "artifact",
"provider": "paperclip",
"title": "Walkthrough render",
"status": "ready_for_review",
"reviewState": "needs_board_review",
"isPrimary": true,
"metadata": { "attachmentId": "<uploaded-attachment-id>" }
}'
```
In your final issue comment, link the uploaded attachment or work product and
describe what it contains. If the output is workspace-only, name the work
product and the relative path that was recorded in `metadata.resourceRef`.
Browse/search is the fallback for recovering a workspace file when the issue
chip or link cannot open it; it is not the preferred deliverable path. Do not
leave artifact-producing work `in_progress` with only a local path or a
`Remaining` note.

View File

@ -0,0 +1,299 @@
# Cases
Cases are agent-owned work records for durable outputs such as blog posts,
research packets, release notes, incidents, QA runs, or generated asset sets.
They are company-scoped and live beside issues: issues coordinate work, while
cases preserve the structured object an agent is producing.
Cases are experimental and must be enabled with `experimental.enableCases`.
If a route returns `403 Cases are disabled`, stop and report that the operator
must enable cases before the skill can use this surface.
## Core Model
A case has:
- `identifier`: server-assigned display id such as `PAP-C42`
- `caseType`: skill-owned type such as `blog_post`, `image_assets`, or `incident`
- `key`: optional deterministic upsert key inside `(companyId, caseType)`
- `title` and optional `summary`
- `status`: `draft`, `in_progress`, `in_review`, `approved`, `done`, or `cancelled`
- `fields`: JSON object owned by the skill using the case
- `parentCaseId`: optional parent case for child work
- documents, attachments, issue links, labels, and events
Use deterministic `caseType` + `key` when a skill may be retried. Repeating
`POST /api/companies/:companyId/cases` with the same `caseType` and `key`
upserts the same case instead of creating a duplicate.
Set `status` to match reality at creation time: if you are producing or
revising the record in this same session, create it as `in_progress`; use
`draft` only for a stub you are deliberately parking for later.
## Upsert Semantics
`POST /api/companies/:companyId/cases` creates or upserts a case.
Request:
```json
{
"caseType": "blog_post",
"key": "launch-announcement",
"title": "Launch announcement",
"summary": "Draft launch post for operators.",
"status": "in_progress",
"fields": {
"slug": "launch-announcement",
"target_audience": "operators"
}
}
```
Response:
- `201` when a new case was created
- `200` when an existing `(caseType, key)` case was updated
Field behavior on upsert:
- `title` is required and replaces the previous title.
- `projectId`, `summary`, `status`, `fields`, and `parentCaseId` replace the
previous value when present.
- Omitted optional values preserve the previous value during upsert.
- `fields` is replaced as a whole object when provided. It is not deep-merged.
Send the complete desired JSON object each time.
- Concurrent retries with the same `(caseType, key)` converge to one case.
Do not use a random `key` for retryable skills. Use a stable content slug,
external id, source URL hash, or parent-derived request key.
## Read And Search
Get a case by UUID or identifier:
```http
GET /api/cases/PAP-C42
```
List cases for a company:
```http
GET /api/companies/:companyId/cases?type=blog_post&status=active&q=launch
```
Useful filters:
- `type`: exact `caseType`
- `status`: exact lifecycle status, or `active` for non-terminal cases
- `projectId` / `project`: project UUID
- `labelId` / `label`: label UUID
- `q`: identifier, title, summary, or key search
- `limit`: 1-200, default 100
## Documents
Use case documents for rich bodies such as drafts, briefs, reports, or plans.
```http
PUT /api/cases/:caseIdOrIdentifier/documents/body
Content-Type: application/json
{
"title": "Launch announcement body",
"format": "markdown",
"body": "# Launch announcement\n\nDraft copy...",
"changeSummary": "Initial draft"
}
```
Updating an existing case document requires `baseRevisionId`:
```json
{
"baseRevisionId": "latest-revision-uuid",
"body": "Updated body"
}
```
If you get `409 stale_base_revision`, refetch the case detail, read the latest
document revision id, merge intentionally, and retry with that `baseRevisionId`.
## Fields
Each skill owns the schema of `fields` for the `caseType` it creates. Keep fields
small, typed, and stable enough for other agents to inspect.
Examples:
```json
{
"slug": "launch-announcement",
"target_audience": "operators",
"publish_url": "https://example.com/blog/launch-announcement"
}
```
Patch fields or status with:
```http
PATCH /api/cases/:caseIdOrIdentifier
Content-Type: application/json
{
"status": "in_review",
"fields": {
"slug": "launch-announcement",
"target_audience": "operators",
"publish_url": "https://example.com/blog/launch-announcement"
}
}
```
Remember: `fields` replaces the whole object when present.
## Issue Links
Link cases to issues explicitly when needed:
```http
POST /api/cases/:caseIdOrIdentifier/links
Content-Type: application/json
{
"issueId": "issue-uuid",
"role": "reference"
}
```
Roles:
- `origin`: the issue/run that created the case
- `work`: an issue/run that changed the case
- `reference`: related issue context
Agent run writes auto-link the run's issue when Paperclip can resolve it from
the run JWT or `X-Paperclip-Run-Id`. Creation/upsert writes use `origin`; later
document, patch, and attachment writes use `work` when no link already exists.
You do not need to manually link the current issue before writing the case.
## Child Cases
Create child cases by setting `parentCaseId` to the parent case UUID.
```json
{
"caseType": "image_assets",
"key": "launch-announcement:hero-images",
"title": "Hero images for launch announcement",
"parentCaseId": "parent-case-uuid",
"fields": {
"required_assets": ["hero", "social-card"]
}
}
```
Use child cases when the output has independently inspectable pieces or when
another agent can work on a bounded part without editing the parent case body.
## Attachments
Attach generated files with multipart form data:
```http
POST /api/cases/:caseIdOrIdentifier/attachments
Content-Type: multipart/form-data
file=@hero.png
```
The server records an asset and adds an `attachment_added` case event.
## Lifecycle
Use the lifecycle consistently:
- `draft`: case exists but useful work has not started
- `in_progress`: an agent is actively producing or revising it
- `in_review`: ready for reviewer, board, or downstream approval
- `approved`: accepted but not finally shipped or archived
- `done`: complete and no further action remains
- `cancelled`: intentionally abandoned
Terminal statuses are `done` and `cancelled`; setting either records
`completedAt`. Moving back to a non-terminal status clears `completedAt`.
## Worked Blog Post Example
Create or upsert the parent blog post:
```http
POST /api/companies/:companyId/cases
Content-Type: application/json
{
"caseType": "blog_post",
"key": "paperclip-cases-launch",
"title": "Introducing Paperclip Cases",
"summary": "Blog post explaining the cases surface for agent outputs.",
"status": "in_progress",
"fields": {
"slug": "paperclip-cases-launch",
"target_audience": "AI company operators",
"publish_url": null
}
}
```
Write the body:
```http
PUT /api/cases/PAP-C42/documents/body
Content-Type: application/json
{
"title": "Introducing Paperclip Cases",
"format": "markdown",
"body": "# Introducing Paperclip Cases\n\n..."
}
```
Create the child image-assets case:
```http
POST /api/companies/:companyId/cases
Content-Type: application/json
{
"caseType": "image_assets",
"key": "paperclip-cases-launch:image-assets",
"title": "Image assets for Introducing Paperclip Cases",
"parentCaseId": "parent-case-uuid",
"status": "in_progress",
"fields": {
"slug": "paperclip-cases-launch",
"required_assets": ["hero", "social-card"],
"publish_url": null
}
}
```
Attach generated assets to the child, then patch both cases as they move through
review:
```http
PATCH /api/cases/PAP-C42
Content-Type: application/json
{
"status": "in_review",
"fields": {
"slug": "paperclip-cases-launch",
"target_audience": "AI company operators",
"publish_url": "https://example.com/blog/paperclip-cases-launch"
}
}
```
If the same skill retries the example with the same keys, it updates the parent
and child cases rather than creating duplicates.

View File

@ -0,0 +1,259 @@
# Company Skills Workflow
Use this reference when a board user, CEO, or manager asks you to find a skill, install it into the company library, or assign it to an agent.
## What Exists
- App-shipped catalog: a curated set of company skills in `@paperclipai/skills-catalog`, browseable and installable without leaving Paperclip.
- Company skill library: install, inspect, update, audit, reset, and read company skills for the whole company.
- Agent skill assignment: add or remove company skills on an existing agent.
- Hire/create composition: pass `desiredSkills` when creating or hiring an agent so the same assignment model applies immediately.
The canonical model is:
1. add the skill to the company library — either from the app catalog (`skills install`), an external source (`skills import`), or a managed local skill (`skills create`/`skills scan-projects`)
2. attach the company skill to the agent (`skills agent sync`)
3. optionally do step 2 during hire/create with `desiredSkills`
Catalog install ≠ agent attach. Installing a catalog skill only adds the row to
`company_skills`. The agent will not use it until you sync the agent's desired
set.
## Permission Model
- Company skill reads: any same-company actor
- Company skill mutations: board, a human/agent principal with an explicit `skills:create` grant, or an agent whose `canCreateSkills` permission is enabled. `canCreateSkills` defaults on for agents unless explicitly disabled.
- Agent skill assignment: same permission model as updating that agent
- Team installs continue to require `agents:create` because they import or create agents in addition to attaching skills.
## Core Endpoints
App-shipped catalog (read-only browse + company install):
- `GET /api/skills/catalog`
- `GET /api/skills/catalog/:catalogId`
- `GET /api/skills/catalog/ref?ref=<id|key|slug>`
- `GET /api/skills/catalog/:catalogId/files?path=SKILL.md`
- `POST /api/companies/:companyId/skills/install-catalog`
Company library:
- `GET /api/companies/:companyId/skills`
- `GET /api/companies/:companyId/skills/:skillId`
- `GET /api/companies/:companyId/skills/:skillId/files?path=SKILL.md`
- `POST /api/companies/:companyId/skills` (managed local create)
- `POST /api/companies/:companyId/skills/import`
- `POST /api/companies/:companyId/skills/scan-projects`
- `GET /api/companies/:companyId/skills/:skillId/update-status`
- `POST /api/companies/:companyId/skills/:skillId/install-update`
- `POST /api/companies/:companyId/skills/:skillId/audit`
- `POST /api/companies/:companyId/skills/:skillId/reset`
- `DELETE /api/companies/:companyId/skills/:skillId`
Agent attach and hire/create composition:
- `GET /api/agents/:agentId/skills`
- `POST /api/agents/:agentId/skills/sync`
- `POST /api/companies/:companyId/agent-hires`
- `POST /api/companies/:companyId/agents`
If a board user, CEO, or manager is driving locally, prefer the
`paperclipai skills` CLI documented in `doc/CLI.md` — it wraps every endpoint
above, accepts company skill or catalog refs by `id`/`key`/`slug`, and prints
the same JSON these endpoints return when called with `--json`.
## Install A Skill Into The Company
Two paths cover the common cases:
1. **App-shipped catalog** (preferred when the right skill exists in the
bundled/optional catalog) — browse it first, then install with the catalog
install endpoint. No external network fetch happens.
2. **External source** (skills.sh, GitHub, local path, or URL) — use the
import endpoint below.
### App-shipped catalog
Browse, inspect, and install catalog skills before reaching for an external
source. Bundled skills are the curated defaults for any company; optional
skills are role- or domain-specific.
```sh
curl -sS "$PAPERCLIP_API_URL/api/skills/catalog?kind=bundled" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -sS "$PAPERCLIP_API_URL/api/skills/catalog/ref?ref=github-pr-workflow" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/install-catalog" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"catalogSkillId": "paperclipai:bundled:software-development:github-pr-workflow"
}'
```
The install response records provenance (`catalogId`, `catalogKey`,
`packageVersion`, `originHash`) on the company skill so update/audit/reset
flows know the pinned origin. `force: true` may replace a same-key
catalog-managed skill but never bypasses hard-stop audit findings.
### External source import
Import using a **skills.sh URL**, a key-style source string, a GitHub URL, or a local path.
### Source types (in order of preference)
| Source format | Example | When to use |
|---|---|---|
| **skills.sh URL** | `https://skills.sh/google-labs-code/stitch-skills/design-md` | When a user gives you a `skills.sh` link. This is the managed skill registry — **always prefer it when available**. |
| **Key-style string** | `google-labs-code/stitch-skills/design-md` | Shorthand for the same skill — `org/repo/skill-name` format. Equivalent to the skills.sh URL. |
| **GitHub URL** | `https://github.com/vercel-labs/agent-browser` | When the skill is in a GitHub repo but not on skills.sh. |
| **Local path** | `/abs/path/to/skill-dir` | When the skill is on disk (dev/testing only). |
**Critical:** If a user gives you a `https://skills.sh/...` URL, use that URL or its key-style equivalent (`org/repo/skill-name`) as the `source`. Do **not** convert it to a GitHub URL — skills.sh is the managed registry and the source of truth for versioning, discovery, and updates.
### Example: skills.sh import (preferred)
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "https://skills.sh/google-labs-code/stitch-skills/design-md"
}'
```
Or equivalently using the key-style string:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "google-labs-code/stitch-skills/design-md"
}'
```
### Example: GitHub import
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/import" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "https://github.com/vercel-labs/agent-browser"
}'
```
You can also use source strings such as:
- `google-labs-code/stitch-skills/design-md`
- `vercel-labs/agent-browser/agent-browser`
- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser`
If the task is to discover skills from the company project workspaces first:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/scan-projects" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
```
## Inspect What Was Installed
```sh
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
```
Read the skill entry and its `SKILL.md`:
```sh
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/<skill-id>" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills/<skill-id>/files?path=SKILL.md" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
```
## Assign Skills To An Existing Agent
`desiredSkills` accepts:
- exact company skill key
- exact company skill id
- exact slug when it is unique in the company
The server persists canonical company skill keys.
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/agents/<agent-id>/skills/sync" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"desiredSkills": [
"vercel-labs/agent-browser/agent-browser"
]
}'
```
If you need the current state first:
```sh
curl -sS "$PAPERCLIP_API_URL/api/agents/<agent-id>/skills" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY"
```
## Include Skills During Hire Or Create
Use the same company skill keys or references in `desiredSkills` when hiring or creating an agent:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "QA Browser Agent",
"role": "qa",
"adapterType": "codex_local",
"adapterConfig": {
"cwd": "/abs/path/to/repo"
},
"desiredSkills": [
"agent-browser"
]
}'
```
For direct create without approval:
```sh
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agents" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "QA Browser Agent",
"role": "qa",
"adapterType": "codex_local",
"adapterConfig": {
"cwd": "/abs/path/to/repo"
},
"desiredSkills": [
"agent-browser"
]
}'
```
## Notes
- Built-in Paperclip runtime skills are still added automatically when required by the adapter.
- If a reference is missing or ambiguous, the API returns `422`.
- Prefer linking back to the relevant issue, approval, and agent when you comment about skill changes.
- Use company portability routes when you need whole-package import/export, not just a skill:
- `POST /api/companies/:companyId/imports/preview`
- `POST /api/companies/:companyId/imports/apply`
- `POST /api/companies/:companyId/exports/preview`
- `POST /api/companies/:companyId/exports`
- Use skill-only import when the task is specifically to add a skill to the company library without importing the surrounding company/team/package structure.

View File

@ -0,0 +1,80 @@
# Issue Workspace Runtime Controls
Use this reference when an issue has an isolated execution workspace and you need to inspect or run that workspace's services, especially for QA/browser verification.
## Discover the Workspace
Start from the issue, not from memory:
```sh
curl -sS -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/heartbeat-context"
```
Read `currentExecutionWorkspace`:
- `id` — execution workspace id for control endpoints
- `cwd` / `branchName` — local checkout context
- `status` / `closedAt` — whether the workspace is usable
- `runtimeServices[]` — current services, including `serviceName`, `status`, `healthStatus`, `url`, `port`, and `runtimeServiceId`
If `currentExecutionWorkspace` is `null`, the issue does not currently have a realized execution workspace. For child/follow-up work, create the child with `parentId` or use `inheritExecutionWorkspaceFromIssueId` so Paperclip preserves workspace continuity.
## Control Services
Prefer Paperclip-managed runtime service controls over manual `pnpm dev &` or ad-hoc background processes. These endpoints keep service state, URLs, logs, and ownership visible to other agents and the board.
```sh
# Start all configured services; waits for configured readiness checks.
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/execution-workspaces/<workspace-id>/runtime-services/start" \
-d '{}'
# Restart all configured services.
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/execution-workspaces/<workspace-id>/runtime-services/restart" \
-d '{}'
# Stop all running services.
curl -sS -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H "Content-Type: application/json" \
"$PAPERCLIP_API_URL/api/execution-workspaces/<workspace-id>/runtime-services/stop" \
-d '{}'
```
To target a configured service, pass one of:
```json
{ "workspaceCommandId": "web" }
{ "runtimeServiceId": "<runtime-service-id>" }
{ "serviceIndex": 0 }
```
The response includes an updated `workspace.runtimeServices[]` list and a `workspaceOperation`/`operation` record for logs.
## Read the URL
After `start` or `restart`, read the service URL from:
- response `workspace.runtimeServices[].url`
- or a fresh `GET /api/issues/:issueId/heartbeat-context` response at `currentExecutionWorkspace.runtimeServices[].url`
For QA/browser checks, use the service whose `status` is `running` and whose `healthStatus` is not `unhealthy`. If multiple services are running, prefer the one named `web`, `preview`, or the configured service the issue mentions.
## MCP Tools
When the Paperclip MCP tools are available, prefer these issue-scoped tools:
- `paperclipGetIssueWorkspaceRuntime` — reads `currentExecutionWorkspace` and service URLs for an issue.
- `paperclipControlIssueWorkspaceServices` — starts, stops, or restarts the current issue workspace services.
- `paperclipWaitForIssueWorkspaceService` — waits until a selected service is running and returns its URL when exposed.
These tools resolve the issue's workspace id for you, so QA agents do not need to know the lower-level execution workspace endpoint first.

View File

@ -0,0 +1,187 @@
# Paperclip Routines
Routines are recurring tasks. Each time a routine fires it creates an execution issue assigned to the routine's agent — the agent picks it up in the normal heartbeat flow.
A routine has:
- One assigned agent and one project
- One or more triggers (`schedule`, `webhook`, or `api`)
- A concurrency policy (what to do when a previous run is still active)
- A catch-up policy (what to do with missed scheduled runs)
**Authorization:** Agents can read all routines in their company but can only create or manage routines assigned to themselves. Board operators have full access, including reassignment.
---
## Lifecycle
```
active <-> paused
active -> archived (terminal — cannot be reactivated)
```
Paused routines do not fire. Archived routines do not fire and cannot be unarchived.
---
## Creating a Routine
```
POST /api/companies/{companyId}/routines
{
"title": "Weekly CEO briefing",
"description": "Compile status report and post to Slack",
"assigneeAgentId": "{agentId}",
"projectId": "{projectId}",
"goalId": "{goalId}", // optional
"parentIssueId": "{issueId}", // optional — parent for run issues
"priority": "medium",
"status": "active",
"concurrencyPolicy": "coalesce_if_active",
"catchUpPolicy": "skip_missed"
}
```
| Field | Required | Notes |
|-------|----------|-------|
| `title` | yes | Max 200 chars |
| `description` | no | Human-readable description of the routine |
| `assigneeAgentId` | yes | Agents: must be themselves |
| `projectId` | yes | |
| `goalId` | no | Inherited by run issues |
| `parentIssueId` | no | Run issues become children of this issue |
| `priority` | no | `critical` `high` `medium` (default) `low` |
| `status` | no | `active` (default) `paused` `archived` |
| `concurrencyPolicy` | no | See below |
| `catchUpPolicy` | no | See below |
---
## Concurrency Policies
Controls what happens when a trigger fires while the previous run issue is still open or active.
| Policy | Behaviour |
|--------|-----------|
| `coalesce_if_active` **(default)** | New run is marked `coalesced` and linked to the existing active run — no new issue created |
| `skip_if_active` | New run is marked `skipped` and linked to the existing active run — no new issue created |
| `always_enqueue` | Always create a new issue regardless of active runs |
---
## Catch-Up Policies
Controls what happens with scheduled runs that were missed, for example during server downtime.
| Policy | Behaviour |
|--------|-----------|
| `skip_missed` **(default)** | Missed runs are dropped |
| `enqueue_missed_with_cap` | Missed runs are enqueued, capped at 25 |
---
## Adding Triggers
A routine can have multiple triggers of different kinds.
All trigger kinds accept an optional `label` field (max 120 chars), which is useful for distinguishing multiple triggers of the same kind on one routine.
```
POST /api/routines/{routineId}/triggers
```
### Schedule (cron)
```json
{
"kind": "schedule",
"cronExpression": "0 9 * * 1",
"timezone": "Europe/Amsterdam"
}
```
- `cronExpression`: standard 5-field cron syntax
- `timezone`: IANA timezone string (for example `UTC` or `America/New_York`)
- The server computes `nextRunAt` automatically
### Webhook
```json
{
"kind": "webhook",
"signingMode": "hmac_sha256",
"replayWindowSec": 300
}
```
- `signingMode`: `bearer` (default) or `hmac_sha256`
- `replayWindowSec`: 30-86400 (default 300)
- Response includes the webhook URL (`publicId`-based) and the signing secret
- Fire externally: `POST /api/routine-triggers/public/{publicId}/fire`
- Bearer: `Authorization: Bearer <secret>`
- HMAC: `X-Paperclip-Signature` + `X-Paperclip-Timestamp` headers
### API (manual only)
```json
{
"kind": "api"
}
```
No configuration. Fire via the manual run endpoint.
---
## Updating and Deleting Triggers
```
PATCH /api/routine-triggers/{triggerId}
{ "enabled": false, "cronExpression": "0 10 * * 1" }
DELETE /api/routine-triggers/{triggerId}
```
To rotate a webhook secret (the old secret is immediately invalidated):
```
POST /api/routine-triggers/{triggerId}/rotate-secret
```
---
## Manual Run
Fires a run immediately, bypassing the schedule. Concurrency policy still applies.
```
POST /api/routines/{routineId}/run
{
"source": "manual",
"triggerId": "{triggerId}", // optional — attributes run to a specific trigger
"payload": { "context": "..." }, // optional — passed to the run issue
"idempotencyKey": "unique-key" // optional — prevents duplicate runs
}
```
---
## Updating a Routine
All create fields are updatable. Agents cannot reassign a routine to another agent.
```
PATCH /api/routines/{routineId}
{ "status": "paused", "title": "New title" }
```
---
## Reading Routines and Runs
```
GET /api/companies/{companyId}/routines
GET /api/routines/{routineId}
GET /api/routines/{routineId}/runs?limit=50
```
Use the generic API endpoint tables in `skills/paperclip/references/api-reference.md` when you need a full cross-domain reference. Use this file when you need routine-specific behaviour, payload shape, or policy details.

View File

@ -0,0 +1,141 @@
# Paperclip Workflow Playbooks
Reference material for niche workflows that are pointed to from `SKILL.md`. Load only when the task matches.
---
## Project Setup (CEO/Manager)
When asked to set up a new project with workspace config (local folder and/or GitHub repo):
1. `POST /api/companies/{companyId}/projects` with project fields.
2. Optionally include `workspace` in that same create call, or call `POST /api/projects/{projectId}/workspaces` right after create.
Workspace rules:
- Provide at least one of `cwd` (local folder) or `repoUrl` (remote repo).
- For repo-only setup, omit `cwd` and provide `repoUrl`.
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
---
## OpenClaw Invite (CEO)
Use this when asked to invite a new OpenClaw employee.
1. Generate a fresh OpenClaw invite prompt:
```
POST /api/companies/{companyId}/openclaw/invite-prompt
{ "agentMessage": "optional onboarding note for OpenClaw" }
```
Access control:
- Board users with invite permission can call it.
- Agent callers: only the company CEO agent can call it.
2. Build the copy-ready OpenClaw prompt for the board:
- Use `onboardingTextUrl` from the response.
- Ask the board to paste that prompt into OpenClaw.
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
3. Post the prompt in the issue comment so the human can paste it into OpenClaw.
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
---
## Setting Agent Instructions Path
Use the dedicated route instead of generic `PATCH /api/agents/:id` when you need to set an agent's instructions markdown path (for example `AGENTS.md`).
```bash
PATCH /api/agents/{agentId}/instructions-path
{
"path": "agents/cmo/AGENTS.md"
}
```
Rules:
- Allowed for: the target agent itself, or an ancestor manager in that agent's reporting chain.
- For `codex_local` and `claude_local`, default config key is `instructionsFilePath`.
- Relative paths are resolved against the target agent's `adapterConfig.cwd`; absolute paths are accepted as-is.
- To clear the path, send `{ "path": null }`.
- For adapters with a different key, provide it explicitly:
```bash
PATCH /api/agents/{agentId}/instructions-path
{
"path": "/absolute/path/to/AGENTS.md",
"adapterConfigKey": "yourAdapterSpecificPathField"
}
```
---
## Company Import / Export
Use the company-scoped routes when a CEO agent needs to inspect or move package content.
- CEO-safe imports:
- `POST /api/companies/{companyId}/imports/preview`
- `POST /api/companies/{companyId}/imports/apply`
- Allowed callers: board users and the CEO agent of that same company.
- Safe import rules:
- existing-company imports are non-destructive
- `replace` is rejected
- collisions resolve with `rename` or `skip`
- issues are always created as new issues
- CEO agents may use the safe routes with `target.mode = "new_company"` to create a new company directly. Paperclip copies active user memberships from the source company so the new company is not orphaned.
For export, preview first, then **always produce**. An export ask is a two-step action whose required terminal write is the produced export — the preview is never the deliverable:
1. `POST /api/companies/{companyId}/exports/preview` — inspect the package inventory. Preview defaults to `issues: false`.
2. `POST /api/companies/{companyId}/exports` — produce the package, narrowed with `selectedFiles` to the specific agents, skills, projects, or tasks you confirmed in the preview inventory. `selectedFiles` is the narrowing mechanism; an `include` map alone does not narrow the final package.
- Add `issues` or `projectIssues` only when you intentionally need task files.
- Stopping after the preview — or answering with an inventory table/report instead of sending the producing POST — leaves the export unproduced. The ask is complete only when the `POST /exports` with `selectedFiles` has returned 2xx.
See `api-reference.md` for full schema examples.
---
## Self-Test Playbook (App-Level)
Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions).
1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
```bash
npx paperclipai issue create \
--company-id "$PAPERCLIP_COMPANY_ID" \
--title "Self-test: assignment/watch flow" \
--description "Temporary validation issue" \
--status todo \
--assignee-agent-id "$PAPERCLIP_AGENT_ID"
```
2. Trigger and watch a heartbeat for that assignee:
```bash
npx paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID"
```
3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
```bash
npx paperclipai issue get <issue-id-or-identifier>
```
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
```bash
npx paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
```
5. Cleanup: mark temporary issues done/cancelled with a clear note.
If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat.

View File

@ -0,0 +1,371 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
paperclip-upload-artifact.sh FILE [options]
Uploads a generated file from the current workspace to the current Paperclip
issue, then creates an attachment-backed artifact work product by default.
Required environment for live uploads:
PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_COMPANY_ID, PAPERCLIP_TASK_ID, PAPERCLIP_RUN_ID
Options:
--issue-id ID Issue id to attach to (default: PAPERCLIP_TASK_ID)
--company-id ID Company id (default: PAPERCLIP_COMPANY_ID)
--title TEXT Work product title (default: file basename)
--summary TEXT Work product summary
--content-type TYPE Override detected upload content type
--status STATUS Work product status (default: ready_for_review)
--no-work-product Only upload the issue attachment
--no-primary Do not mark the artifact work product primary for its type
--output FORMAT markdown or json (default: markdown)
--dry-run Print resolved upload settings without calling the API
--help, -h Show this help
Examples:
scripts/paperclip-upload-artifact.sh dist/demo.mp4 \
--title "Demo video render" \
--summary "MP4 render for board review"
scripts/paperclip-upload-artifact.sh out/walkthrough.webm \
--title "Walkthrough video" \
--content-type video/webm
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Missing required command: %s\n' "$1" >&2
exit 1
fi
}
json_bool() {
if [[ "${1:-0}" == "1" ]]; then
printf 'true'
else
printf 'false'
fi
}
detect_content_type() {
local path="$1"
local lower
lower="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
*.mp4|*.m4v) printf 'video/mp4' ;;
*.webm) printf 'video/webm' ;;
*.mov|*.qt) printf 'video/quicktime' ;;
*.png) printf 'image/png' ;;
*.jpg|*.jpeg) printf 'image/jpeg' ;;
*.gif) printf 'image/gif' ;;
*.webp) printf 'image/webp' ;;
*.svg) printf 'image/svg+xml' ;;
*.pdf) printf 'application/pdf' ;;
*.txt|*.log) printf 'text/plain' ;;
*.md|*.markdown) printf 'text/markdown' ;;
*.json) printf 'application/json' ;;
*.csv) printf 'text/csv' ;;
*.html|*.htm) printf 'text/html' ;;
*.zip) printf 'application/zip' ;;
*)
if command -v file >/dev/null 2>&1; then
file --brief --mime-type "$path"
else
printf 'application/octet-stream'
fi
;;
esac
}
request_json() {
local method="$1"
local url="$2"
local body="${3:-}"
local response_file
local status_code
response_file="$(mktemp)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-H 'Content-Type: application/json' \
--data-binary "$body"
)"
else
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"
)"
fi
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Request failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
upload_file() {
local url="$1"
local path="$2"
local content_type="$3"
local escaped_path
local response_file
local status_code
escaped_path="${path//\\/\\\\}"
escaped_path="${escaped_path//\"/\\\"}"
response_file="$(mktemp)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
-F "file=@\"${escaped_path}\";type=${content_type}"
)"
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
printf 'Upload failed (%s): %s\n' "$status_code" "$url" >&2
cat "$response_file" >&2
printf '\n' >&2
rm -f "$response_file"
exit 1
fi
cat "$response_file"
rm -f "$response_file"
}
file_path=""
issue_id="${PAPERCLIP_TASK_ID:-}"
company_id="${PAPERCLIP_COMPANY_ID:-}"
title=""
summary=""
content_type=""
status="ready_for_review"
create_work_product=1
is_primary=1
output_format="markdown"
dry_run=0
while [[ $# -gt 0 ]]; do
case "$1" in
--issue-id)
issue_id="${2:-}"
shift 2
;;
--company-id)
company_id="${2:-}"
shift 2
;;
--title)
title="${2:-}"
shift 2
;;
--summary)
summary="${2:-}"
shift 2
;;
--content-type)
content_type="${2:-}"
shift 2
;;
--status)
status="${2:-}"
shift 2
;;
--no-work-product)
create_work_product=0
shift
;;
--no-primary)
is_primary=0
shift
;;
--output)
output_format="${2:-}"
shift 2
;;
--dry-run)
dry_run=1
shift
;;
--help|-h)
usage
exit 0
;;
--*)
printf 'Unknown argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
*)
if [[ -n "$file_path" ]]; then
printf 'Unexpected positional argument: %s\n' "$1" >&2
usage >&2
exit 1
fi
file_path="$1"
shift
;;
esac
done
if [[ -z "$file_path" ]]; then
printf 'Missing file path.\n' >&2
usage >&2
exit 1
fi
if [[ ! -f "$file_path" ]]; then
printf 'Artifact file does not exist: %s\n' "$file_path" >&2
exit 1
fi
if [[ "$output_format" != "markdown" && "$output_format" != "json" ]]; then
printf 'Unsupported output format: %s\n' "$output_format" >&2
exit 1
fi
require_command curl
require_command jq
if [[ -z "$title" ]]; then
title="$(basename "$file_path")"
fi
if [[ -z "$content_type" ]]; then
content_type="$(detect_content_type "$file_path")"
fi
if [[ "$dry_run" == "1" ]]; then
create_work_product_json="$(json_bool "$create_work_product")"
is_primary_json="$(json_bool "$is_primary")"
jq -n \
--arg file "$file_path" \
--arg issueId "$issue_id" \
--arg companyId "$company_id" \
--arg title "$title" \
--arg summary "$summary" \
--arg contentType "$content_type" \
--arg status "$status" \
--argjson createWorkProduct "$create_work_product_json" \
--argjson isPrimary "$is_primary_json" \
'{file: $file, issueId: $issueId, companyId: $companyId, title: $title, summary: $summary, contentType: $contentType, status: $status, createWorkProduct: $createWorkProduct, isPrimary: $isPrimary}'
exit 0
fi
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
exit 1
fi
if [[ -z "$issue_id" || -z "$company_id" ]]; then
printf 'Missing issue or company id. Pass --issue-id/--company-id or set PAPERCLIP_TASK_ID/PAPERCLIP_COMPANY_ID.\n' >&2
exit 1
fi
api_base="${PAPERCLIP_API_URL%/}/api"
attachment="$(
upload_file \
"$api_base/companies/$company_id/issues/$issue_id/attachments" \
"$file_path" \
"$content_type"
)"
work_product="null"
if [[ "$create_work_product" == "1" ]]; then
is_primary_json="$(json_bool "$is_primary")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
byte_size="$(jq -r '.byteSize // 0' <<<"$attachment")"
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
open_path="$(jq -r '.openPath // .contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
original_filename="$(jq -r '.originalFilename // empty' <<<"$attachment")"
if [[ -z "$attachment_id" || -z "$content_path" || -z "$download_path" ]]; then
printf 'Upload response did not include attachment path metadata.\n' >&2
printf '%s\n' "$attachment" >&2
exit 1
fi
work_product_payload="$(
jq -nc \
--arg title "$title" \
--arg summary "$summary" \
--arg status "$status" \
--arg runId "$PAPERCLIP_RUN_ID" \
--arg attachmentId "$attachment_id" \
--arg contentType "$content_type" \
--argjson byteSize "$byte_size" \
--arg contentPath "$content_path" \
--arg openPath "$open_path" \
--arg downloadPath "$download_path" \
--arg originalFilename "$original_filename" \
--argjson isPrimary "$is_primary_json" \
'{
type: "artifact",
provider: "paperclip",
title: $title,
status: $status,
reviewState: "none",
isPrimary: $isPrimary,
healthStatus: "unknown",
summary: (if $summary == "" then null else $summary end),
createdByRunId: $runId,
metadata: {
attachmentId: $attachmentId,
contentType: $contentType,
byteSize: $byteSize,
contentPath: $contentPath,
openPath: $openPath,
downloadPath: $downloadPath,
originalFilename: (if $originalFilename == "" then null else $originalFilename end)
}
}'
)"
work_product="$(
request_json \
POST \
"$api_base/issues/$issue_id/work-products" \
"$work_product_payload"
)"
fi
if [[ "$output_format" == "json" ]]; then
jq -n --argjson attachment "$attachment" --argjson workProduct "$work_product" \
'{attachment: $attachment, workProduct: $workProduct}'
exit 0
fi
content_path="$(jq -r '.contentPath // empty' <<<"$attachment")"
download_path="$(jq -r '.downloadPath // (if .contentPath then (.contentPath + "?download=1") else "" end)' <<<"$attachment")"
attachment_id="$(jq -r '.id // empty' <<<"$attachment")"
work_product_id="$(jq -r '.id // empty' <<<"$work_product")"
printf 'Uploaded artifact\n\n'
printf -- '- Attachment: [%s](%s)\n' "$title" "$content_path"
printf -- '- Download: [%s](%s)\n' "$title" "$download_path"
printf -- '- Attachment ID: `%s`\n' "$attachment_id"
if [[ -n "$work_product_id" ]]; then
printf -- '- Work product ID: `%s`\n' "$work_product_id"
fi
printf '\nFinal comment snippet:\n\n'
printf -- '- Artifact: [%s](%s)\n' "$title" "$content_path"

View File

@ -122,6 +122,9 @@ function makeVersion(overrides: Partial<CompanySkillVersion> = {}): CompanySkill
companySkillId: "skill-1",
revisionNumber: 1,
label: null,
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [],
authorAgentId: null,
authorUserId: null,

View File

@ -1573,6 +1573,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--sz-calc-1: calc(100% - 1.5rem); /* Extracted from ui/src/components/ActivityFeed.tsx (w-[calc(100%-1.5rem)]). */
--sz-calc-2: calc(100% - 0.75rem); /* Extracted from ui/src/components/ActivityFeed.tsx (w-[calc(100%-0.75rem)]). */
--sz-16rem: 16rem; /* Extracted from ui/src/components/ActivityFeed.tsx (max-w-[16rem]). */
--sz-20rem: 20rem; /* AgentSkillReleasePicker.tsx release menu width cap. */
--sz-18px: 18px; /* Extracted from ui/src/components/ActivityFeed.tsx (p-[18px]). */
--sz-44px: 44px; /* Extracted from ui/src/components/AgentConfigForm.tsx (min-h-[44px]). */
--sz-88px: 88px; /* Extracted from ui/src/components/AgentConfigForm.tsx (min-h-[88px]). */

View File

@ -122,6 +122,9 @@ function makeVersion(revisionNumber: number, content: string): CompanySkillVersi
companySkillId: "skill-1",
revisionNumber,
label: null,
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [
{
path: "SKILL.md",

View File

@ -57,6 +57,8 @@ const SERVER_INFO_TOGGLE_SELECTOR =
'button[aria-label="Toggle server info debug view experimental setting"]';
const BUILT_IN_AGENTS_TOGGLE_SELECTOR =
'button[aria-label="Toggle built-in agents experimental setting"]';
const BETA_SKILLS_TOGGLE_SELECTOR =
'button[aria-label="Toggle beta skills experimental setting"]';
const APPS_TOGGLE_SELECTOR = 'button[aria-label="Toggle apps experimental setting"]';
const SUMMARIES_TOGGLE_SELECTOR =
'button[aria-label="Toggle summaries experimental setting"]';
@ -78,6 +80,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableBuiltInAgents: false,
enableBetaSkills: false,
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,
@ -446,6 +449,26 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("renders and patches the Beta skills experimental toggle", async () => {
await renderPage();
expect(container.textContent).toContain("Beta skills");
expect(container.textContent).toContain("pin beta releases of the Paperclip core skill");
const toggle = container.querySelector<HTMLButtonElement>(BETA_SKILLS_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableBetaSkills: true,
});
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("renders and patches the Summaries experimental toggle", async () => {
await renderPage();

View File

@ -371,6 +371,7 @@ export function InstanceExperimentalSettings() {
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true;
const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true;
const enableBetaSkills = experimentalQuery.data?.enableBetaSkills === true;
const enableSummaries = experimentalQuery.data?.enableSummaries === true;
const enableStatusCards = experimentalQuery.data?.enableStatusCards === true;
const summariesManaged = managedKeys.enableSummaries?.managed === true;
@ -559,6 +560,16 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle built-in agents experimental setting"
/>
<ExperimentalToggleCard
title="Beta skills"
description="Allow agents to pin beta releases of the Paperclip core skill. Disabling this returns every agent to the default live skill without removing saved pins."
checked={enableBetaSkills}
onCheckedChange={(checked) => toggleMutation.mutate({ enableBetaSkills: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableBetaSkills}
ariaLabel="Toggle beta skills experimental setting"
/>
<ExperimentalToggleCard
title="Summaries"
description="Show Summarizer-generated status slots on project and workspace pages, with on-demand refresh and revision history. Existing summary data is kept when this is disabled."

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { CompanySkillVersion } from "@paperclipai/shared";
import {
formatReleaseDate,
releaseName,
releaseOptionLabel,
releaseShortLabel,
} from "./AgentSkillReleasePicker";
function makeRelease(overrides: Partial<CompanySkillVersion> = {}): CompanySkillVersion {
return {
id: "v7-roster",
companyId: "company-1",
companySkillId: "skill-1",
revisionNumber: 7,
label: "V7 — Roster champion",
releaseId: "v7-roster",
releaseName: "V7 — Roster champion",
releasedAt: "2026-07-21" as unknown as CompanySkillVersion["releasedAt"],
fileInventory: [],
authorAgentId: null,
authorUserId: null,
createdAt: new Date("2026-07-21T00:00:00Z"),
...overrides,
};
}
describe("formatReleaseDate", () => {
it("returns a plain calendar date verbatim", () => {
expect(formatReleaseDate("2026-07-21" as never)).toBe("2026-07-21");
});
it("collapses a full timestamp to its calendar day", () => {
// Anchored to noon UTC so it stays on the 15th regardless of local offset.
expect(formatReleaseDate("2026-07-15T12:00:00Z" as never)).toBe("2026-07-15");
});
it("returns null for missing or unparseable input", () => {
expect(formatReleaseDate(null)).toBeNull();
expect(formatReleaseDate("not-a-date" as never)).toBeNull();
});
});
describe("releaseOptionLabel", () => {
it("renders name and released date", () => {
expect(releaseOptionLabel(makeRelease())).toBe("V7 — Roster champion · released 2026-07-21");
});
it("falls back gracefully without a date", () => {
expect(releaseOptionLabel(makeRelease({ releasedAt: null }))).toBe("V7 — Roster champion");
});
});
describe("releaseName", () => {
it("returns the full release name without the date suffix", () => {
expect(releaseName(makeRelease())).toBe("V7 — Roster champion");
});
it("falls back through label and releaseId", () => {
expect(releaseName(makeRelease({ releaseName: null, label: "Fallback label" }))).toBe(
"Fallback label",
);
expect(
releaseName(makeRelease({ releaseName: null, label: null, releaseId: "raw-id" })),
).toBe("raw-id");
});
});
describe("releaseShortLabel", () => {
it("uses the token before the em-dash separator", () => {
expect(releaseShortLabel(makeRelease())).toBe("V7");
});
it("uses the whole name when there is no separator", () => {
expect(releaseShortLabel(makeRelease({ releaseName: "Champion", label: null }))).toBe("Champion");
});
});

View File

@ -0,0 +1,104 @@
import type { CompanySkillVersion } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/** Sentinel value for the "no pin / live default" option (Radix forbids ""). */
export const RELEASE_DEFAULT_VALUE = "default";
const DEFAULT_LABEL = "Default — current (recommended)";
/**
* Render a bundled release's calendar date. Plain `YYYY-MM-DD` strings (like the
* v7-roster manifest entry) render verbatim; full timestamps collapse to their
* local calendar day so the picker reads `released 2026-07-21`.
*/
export function formatReleaseDate(value: CompanySkillVersion["releasedAt"]): string | null {
if (!value) return null;
const raw = typeof value === "string" ? value : value.toISOString();
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return null;
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, "0");
const day = String(parsed.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** Release display name, e.g. `V7 — Roster champion` (no date). */
export function releaseName(release: CompanySkillVersion): string {
return release.releaseName ?? release.label ?? release.releaseId ?? "Release";
}
/** Full option label, e.g. `V7 — Roster champion · released 2026-07-21`. */
export function releaseOptionLabel(release: CompanySkillVersion): string {
const name = releaseName(release);
const date = formatReleaseDate(release.releasedAt);
return date ? `${name} · released ${date}` : name;
}
/** Compact badge label for a pinned release, e.g. `V7`. */
export function releaseShortLabel(release: CompanySkillVersion): string {
const name = release.releaseName ?? release.label ?? release.releaseId ?? "Release";
return name.split(" — ")[0]!.trim();
}
export interface AgentSkillReleasePickerProps {
releases: CompanySkillVersion[];
/** Currently pinned version id, or null for the live default. */
value: string | null;
disabled?: boolean;
onChange: (versionId: string | null) => void;
}
/**
* Release picker for the paperclip core skill. Only rendered when the
* `enableBetaSkills` experimental flag is on and the skill has seeded releases.
* Selecting a release pins the agent to that frozen snapshot; "Default" clears
* the pin and returns the agent to the live skill.
*/
export function AgentSkillReleasePicker({
releases,
value,
disabled = false,
onChange,
}: AgentSkillReleasePickerProps) {
const selected = value ? releases.find((release) => release.id === value) ?? null : null;
// Closed trigger shows the release name only; the `· released <date>` suffix
// lives in the open menu, where dates are meaningful for comparing options.
const triggerLabel = selected ? releaseName(selected) : DEFAULT_LABEL;
return (
<Select
value={value ?? RELEASE_DEFAULT_VALUE}
disabled={disabled}
onValueChange={(next) => onChange(next === RELEASE_DEFAULT_VALUE ? null : next)}
>
<SelectTrigger
size="sm"
className="w-full max-w-(--sz-16rem) sm:w-(--sz-16rem)"
aria-label="Skill release"
>
<SelectValue placeholder={DEFAULT_LABEL}>{triggerLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="end" className="max-w-(--sz-20rem)">
<SelectItem value={RELEASE_DEFAULT_VALUE}>{DEFAULT_LABEL}</SelectItem>
{releases.map((release) => (
<SelectItem key={release.id} value={release.id}>
<span className="flex items-center gap-2">
<span className="truncate">{releaseOptionLabel(release)}</span>
<Badge variant="secondary" className="shrink-0 text-(length:--text-nano)">
Beta
</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Lock, type LucideIcon } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
@ -38,6 +39,14 @@ export interface AgentSkillRowProps {
/** Tooltip shown on a disabled toggle (unsupported adapter). */
disabledReason?: string | null;
onCheckedChange?: (checked: boolean) => void;
/** Small badge rendered inline after the name (e.g. an active release pin). */
badge?: ReactNode;
/**
* Interactive control rendered in the trailing area before the toggle (e.g.
* the release picker). Kept outside the name Link so it never triggers
* navigation.
*/
accessory?: ReactNode;
}
/**
@ -53,6 +62,8 @@ export function AgentSkillRow({
disabled = false,
disabledReason,
onCheckedChange,
badge,
accessory,
}: AgentSkillRowProps) {
const readOnly = variant === "readonly";
const SourceIcon = data.sourceMeta?.icon;
@ -63,6 +74,7 @@ export function AgentSkillRow({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{data.name}</span>
{badge ? <span className="shrink-0">{badge}</span> : null}
{data.chip ? (
<span className="hidden shrink-0 items-center rounded-full border border-border bg-muted/40 px-2 py-0.5 text-(length:--text-nano) capitalize text-muted-foreground sm:inline-flex">
{data.chip}
@ -89,7 +101,10 @@ export function AgentSkillRow({
);
const rowClass = cn(
"flex min-h-11 items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0",
// Below `sm` the trailing area can't share a line with the leading content,
// so the row wraps and the picker accessory drops onto its own full-width
// line under the name/description (see the `order-last` accessory below).
"flex min-h-11 flex-wrap items-center gap-x-3 gap-y-2 border-b border-border px-3 py-2.5 last:border-b-0 sm:flex-nowrap sm:gap-y-3",
readOnly ? "bg-muted/20" : "transition-colors hover:bg-accent/50",
);
@ -133,6 +148,11 @@ export function AgentSkillRow({
return (
<div className={rowClass}>
{body}
{accessory && !readOnly ? (
// `order-last` + `w-full` push the picker below the name/toggle line on
// mobile; on `sm+` it sits inline between the leading area and the toggle.
<div className="order-last w-full shrink-0 sm:order-none sm:w-auto">{accessory}</div>
) : null}
{trailing}
</div>
);

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { toDesiredSkillPayload } from "./AgentSkillsTab";
describe("toDesiredSkillPayload", () => {
const skillKey = "paperclipai/paperclip/paperclip";
const versionId = "22222222-2222-4222-8222-222222222222";
it("includes saved version pins while beta skills are enabled", () => {
expect(toDesiredSkillPayload([skillKey], { [skillKey]: versionId }, true)).toEqual([
{ key: skillKey, versionId },
]);
});
it("omits saved version pins while beta skills are disabled", () => {
expect(toDesiredSkillPayload([skillKey], { [skillKey]: versionId }, false)).toEqual([
skillKey,
]);
});
});

View File

@ -2,13 +2,15 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, ChevronDown, Loader2, Search, Store, X } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import type { Agent, AgentDesiredSkillEntry } from "@paperclipai/shared";
import { agentsApi } from "../../api/agents";
import { companySkillsApi } from "../../api/companySkills";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { queryKeys } from "../../lib/queryKeys";
import { resolveSkillSummaryText } from "../../lib/company-skill-summary";
import { adapterLabels } from "../../components/agent-config-primitives";
import { cn } from "../../lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -23,14 +25,46 @@ import {
import { AgentSkillRow, type AgentSkillRowData } from "./AgentSkillRow";
import { filterAgentSkills } from "./agent-skill-filter";
import { buildAgentSkillSourceMeta } from "./agent-skill-source";
import { AgentSkillReleasePicker, releaseShortLabel } from "./AgentSkillReleasePicker";
const MATERIALIZATION_NOTE =
"Enabled skills are materialized into the stable Paperclip-managed prompt bundle on the agent's next run.";
/** Company skill key of the Paperclip core skill that carries beta releases. */
const PAPERCLIP_CORE_SKILL_KEY = "paperclipai/paperclip/paperclip";
/** Build the desired-skill sync payload, carrying any active version pins. */
export function toDesiredSkillPayload(
keys: string[],
pins: Record<string, string>,
versionPinsEnabled = true,
): Array<string | AgentDesiredSkillEntry> {
return keys.map((key) => (
versionPinsEnabled && pins[key] ? { key, versionId: pins[key]! } : key
));
}
/** Extract the skill key from either payload shape (string or entry). */
function desiredSkillKey(entry: string | AgentDesiredSkillEntry): string {
return typeof entry === "string" ? entry : entry.key;
}
/** Reduce desired entries to a key → versionId map for non-default pins only. */
function pinsFromEntries(entries: AgentDesiredSkillEntry[] | undefined): Record<string, string> {
const pins: Record<string, string> = {};
for (const entry of entries ?? []) {
if (entry.versionId) pins[entry.key] = entry.versionId;
}
return pins;
}
export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?: string }) {
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
// key → pinned versionId; absence means the live default (no pin).
const [versionPins, setVersionPins] = useState<Record<string, string>>({});
const versionPinsRef = useRef<Record<string, string>>({});
const [search, setSearch] = useState("");
const [detectedOpen, setDetectedOpen] = useState(false);
const lastSavedSkillsRef = useRef<string[]>([]);
@ -52,21 +86,49 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
enabled: Boolean(companyId),
});
// Beta skills experimental flag — gates the per-agent release picker entirely.
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const betaSkillsEnabled = experimentalSettings?.enableBetaSkills === true;
const paperclipCoreSkill = useMemo(
() => (companySkills ?? []).find((skill) => skill.key === PAPERCLIP_CORE_SKILL_KEY) ?? null,
[companySkills],
);
// Seeded releases (release_id IS NOT NULL) for the paperclip core skill. Only
// fetched when the flag is on and the skill is present in the library.
const { data: paperclipVersions } = useQuery({
queryKey: queryKeys.companySkills.versions(companyId ?? "", paperclipCoreSkill?.id ?? ""),
queryFn: () => companySkillsApi.versions(companyId!, paperclipCoreSkill!.id),
enabled: Boolean(companyId && betaSkillsEnabled && paperclipCoreSkill?.id),
});
const paperclipReleases = useMemo(
() => (paperclipVersions ?? []).filter((version) => version.releaseId != null),
[paperclipVersions],
);
const syncSkills = useMutation({
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
mutationFn: (desiredSkills: Array<string | AgentDesiredSkillEntry>) =>
agentsApi.syncSkills(agent.id, desiredSkills, companyId),
onSuccess: async (snapshot) => {
queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
lastSavedSkillsRef.current = snapshot.desiredSkills;
setLastSavedSkills(snapshot.desiredSkills);
const nextPins = pinsFromEntries(snapshot.desiredSkillEntries);
versionPinsRef.current = nextPins;
setVersionPins(nextPins);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
]);
},
onError: (_error, attemptedDesiredSkills) => {
// Remember the payload that failed so the autosave effect stops retrying
// it until the user edits the draft again.
failedSkillDraftRef.current = attemptedDesiredSkills;
// Remember the (keyed) payload that failed so the autosave effect stops
// retrying it until the user edits the draft again.
failedSkillDraftRef.current = attemptedDesiredSkills.map(desiredSkillKey);
},
});
@ -74,11 +136,22 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
setSkillDraft([]);
setLastSavedSkills([]);
lastSavedSkillsRef.current = [];
setVersionPins({});
versionPinsRef.current = {};
hasHydratedSkillSnapshotRef.current = false;
skipNextSkillAutosaveRef.current = true;
failedSkillDraftRef.current = null;
}, [agent.id]);
// Hydrate version pins from the persisted snapshot. Skipped while a save is in
// flight so an optimistic pin selection isn't reverted by stale query data.
useEffect(() => {
if (!skillSnapshot || syncSkills.isPending) return;
const nextPins = pinsFromEntries(skillSnapshot.desiredSkillEntries);
versionPinsRef.current = nextPins;
setVersionPins(nextPins);
}, [skillSnapshot, syncSkills.isPending]);
useEffect(() => {
if (!skillSnapshot) return;
const nextState = applyAgentSkillSnapshot(
@ -121,12 +194,23 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
failedDraft: failedSkillDraftRef.current,
})
) {
syncSkills.mutate(skillDraft);
syncSkills.mutate(toDesiredSkillPayload(
skillDraft,
versionPinsRef.current,
betaSkillsEnabled,
));
}
}, 250);
return () => window.clearTimeout(timeout);
}, [skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.isError, syncSkills.mutate]);
}, [
betaSkillsEnabled,
skillDraft,
skillSnapshot,
syncSkills.isPending,
syncSkills.isError,
syncSkills.mutate,
]);
const companySkillByKey = useMemo(
() => new Map((companySkills ?? []).map((skill) => [skill.key, skill])),
@ -245,17 +329,58 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
);
};
const renderRow = (row: AgentSkillRowData, variant: "enabled" | "available") => (
<AgentSkillRow
key={row.key}
variant={variant}
data={row}
checked={variant === "enabled"}
disabled={unsupported}
disabledReason={unsupportedMessage}
onCheckedChange={(next) => toggleSkill(row.key, next)}
/>
);
// Explicit release selection saves immediately (no autosave debounce), carrying
// the current draft so an in-flight toggle isn't dropped.
const handleReleaseChange = (key: string, versionId: string | null) => {
const nextPins = { ...versionPinsRef.current };
if (versionId) nextPins[key] = versionId;
else delete nextPins[key];
versionPinsRef.current = nextPins;
setVersionPins(nextPins);
syncSkills.mutate(toDesiredSkillPayload(skillDraft, nextPins));
};
// The release picker only applies to the enabled paperclip core skill while the
// beta-skills flag is on and seeded releases exist.
const releasePickerActive = betaSkillsEnabled && paperclipReleases.length > 0;
const renderRow = (row: AgentSkillRowData, variant: "enabled" | "available") => {
const showReleasePicker =
releasePickerActive && variant === "enabled" && row.key === PAPERCLIP_CORE_SKILL_KEY;
const pinnedVersionId = versionPins[row.key] ?? null;
const pinnedRelease = pinnedVersionId
? paperclipReleases.find((release) => release.id === pinnedVersionId) ?? null
: null;
return (
<AgentSkillRow
key={row.key}
variant={variant}
data={row}
checked={variant === "enabled"}
disabled={unsupported}
disabledReason={unsupportedMessage}
onCheckedChange={(next) => toggleSkill(row.key, next)}
badge={
showReleasePicker && pinnedRelease ? (
<Badge variant="secondary" className="text-(length:--text-nano)">
Beta · {releaseShortLabel(pinnedRelease)}
</Badge>
) : undefined
}
accessory={
showReleasePicker ? (
<AgentSkillReleasePicker
releases={paperclipReleases}
value={pinnedVersionId}
disabled={unsupported || syncSkills.isPending}
onChange={(versionId) => handleReleaseChange(row.key, versionId)}
/>
) : undefined
}
/>
);
};
const libraryEmpty = libraryRows.length === 0;

View File

@ -22,6 +22,9 @@ function makeVersion(overrides: Partial<CompanySkillVersion>): CompanySkillVersi
companySkillId: SKILL_ID,
revisionNumber: 1,
label: null,
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [],
authorAgentId: null,
authorUserId: null,

View File

@ -66,6 +66,9 @@ const MOCK_DETAIL: CompanySkillDetail = {
companySkillId: "skill-1",
revisionNumber: 2,
label: "tighten verifier",
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [],
authorAgentId: "a-1",
authorUserId: null,
@ -81,6 +84,9 @@ const MOCK_VERSIONS: CompanySkillVersion[] = [
companySkillId: "skill-1",
revisionNumber: 2,
label: "tighten verifier",
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v2" }],
authorAgentId: "a-1",
authorUserId: null,
@ -92,6 +98,9 @@ const MOCK_VERSIONS: CompanySkillVersion[] = [
companySkillId: "skill-1",
revisionNumber: 1,
label: null,
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v1" }],
authorAgentId: "a-1",
authorUserId: null,