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:
parent
030dd9d15c
commit
c3bd0c5d50
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableExternalObjects: boolean;
|
||||
enableSmokeLab: boolean;
|
||||
enableBuiltInAgents: boolean;
|
||||
enableBetaSkills: boolean;
|
||||
enableSummaries: boolean;
|
||||
enableStatusCards: boolean;
|
||||
enableDecisions: boolean;
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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 1–4 entirely**. Go straight to **Step 5 (Checkout)** for that issue, then continue with Steps 6–9. 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
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]). */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue