From c3bd0c5d50a45cda3c9fddbb0f80a85f44a84242 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:45:59 -0500 Subject: [PATCH] feat(skills): add beta releases for the core Paperclip skill (#10228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../0194_company_skill_releases.sql | 4 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/company_skills.ts | 7 + packages/shared/src/feature-catalog.ts | 7 + packages/shared/src/types/company-skill.ts | 3 + packages/shared/src/types/instance.ts | 1 + .../shared/src/validators/instance.test.ts | 6 + packages/shared/src/validators/instance.ts | 1 + .../src/__tests__/agent-skills-routes.test.ts | 97 ++ .../__tests__/company-skills-service.test.ts | 69 +- .../heartbeat-runtime-skills.test.ts | 48 + .../instance-settings-routes.test.ts | 4 + .../instance-settings-service.test.ts | 7 + server/src/routes/agents.ts | 14 +- server/src/services/company-skills.ts | 136 +- server/src/services/heartbeat.ts | 4 +- server/src/services/instance-settings.ts | 2 + .../src/services/runtime-skill-selections.ts | 8 +- skills-releases/paperclip/releases.json | 16 + skills-releases/paperclip/v0/SKILL.md | 487 +++++++ .../paperclip/v0/references/api-reference.md | 1287 +++++++++++++++++ .../paperclip/v0/references/artifacts.md | 98 ++ .../paperclip/v0/references/cases.md | 295 ++++ .../paperclip/v0/references/company-skills.md | 259 ++++ .../v0/references/issue-workspaces.md | 80 + .../paperclip/v0/references/routines.md | 187 +++ .../paperclip/v0/references/workflows.md | 141 ++ .../v0/scripts/paperclip-upload-artifact.sh | 371 +++++ skills-releases/paperclip/v7-roster/SKILL.md | 623 ++++++++ .../v7-roster/references/api-reference.md | 1287 +++++++++++++++++ .../v7-roster/references/artifacts.md | 98 ++ .../paperclip/v7-roster/references/cases.md | 299 ++++ .../v7-roster/references/company-skills.md | 259 ++++ .../v7-roster/references/issue-workspaces.md | 80 + .../v7-roster/references/routines.md | 187 +++ .../v7-roster/references/workflows.md | 141 ++ .../scripts/paperclip-upload-artifact.sh | 371 +++++ .../AgentsUsingSkillDialog.test.tsx | 3 + ui/src/index.css | 1 + ui/src/pages/CompanySkills.test.tsx | 3 + .../InstanceExperimentalSettings.test.tsx | 23 + ui/src/pages/InstanceExperimentalSettings.tsx | 11 + .../AgentSkillReleasePicker.test.ts | 77 + .../agent-skills/AgentSkillReleasePicker.tsx | 104 ++ ui/src/pages/agent-skills/AgentSkillRow.tsx | 22 +- .../pages/agent-skills/AgentSkillsTab.test.ts | 19 + ui/src/pages/agent-skills/AgentSkillsTab.tsx | 161 ++- .../stories/agents-using-skill.stories.tsx | 3 + .../stories/skills-store-detail.stories.tsx | 9 + 49 files changed, 7394 insertions(+), 33 deletions(-) create mode 100644 packages/db/src/migrations/0194_company_skill_releases.sql create mode 100644 skills-releases/paperclip/releases.json create mode 100644 skills-releases/paperclip/v0/SKILL.md create mode 100644 skills-releases/paperclip/v0/references/api-reference.md create mode 100644 skills-releases/paperclip/v0/references/artifacts.md create mode 100644 skills-releases/paperclip/v0/references/cases.md create mode 100644 skills-releases/paperclip/v0/references/company-skills.md create mode 100644 skills-releases/paperclip/v0/references/issue-workspaces.md create mode 100644 skills-releases/paperclip/v0/references/routines.md create mode 100644 skills-releases/paperclip/v0/references/workflows.md create mode 100644 skills-releases/paperclip/v0/scripts/paperclip-upload-artifact.sh create mode 100644 skills-releases/paperclip/v7-roster/SKILL.md create mode 100644 skills-releases/paperclip/v7-roster/references/api-reference.md create mode 100644 skills-releases/paperclip/v7-roster/references/artifacts.md create mode 100644 skills-releases/paperclip/v7-roster/references/cases.md create mode 100644 skills-releases/paperclip/v7-roster/references/company-skills.md create mode 100644 skills-releases/paperclip/v7-roster/references/issue-workspaces.md create mode 100644 skills-releases/paperclip/v7-roster/references/routines.md create mode 100644 skills-releases/paperclip/v7-roster/references/workflows.md create mode 100644 skills-releases/paperclip/v7-roster/scripts/paperclip-upload-artifact.sh create mode 100644 ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts create mode 100644 ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx create mode 100644 ui/src/pages/agent-skills/AgentSkillsTab.test.ts diff --git a/packages/db/src/migrations/0194_company_skill_releases.sql b/packages/db/src/migrations/0194_company_skill_releases.sql new file mode 100644 index 0000000000..5ebf59ba84 --- /dev/null +++ b/packages/db/src/migrations/0194_company_skill_releases.sql @@ -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; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ece0bc9b85..f74509bb73 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index b1fd42d2af..c40fc52e14 100644 --- a/packages/db/src/schema/company_skills.ts +++ b/packages/db/src/schema/company_skills.ts @@ -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().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, diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index e83601c511..04b37b751f 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -159,6 +159,13 @@ export const INSTANCE_FEATURE_CATALOG: Record { 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({}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 50b686afc4..12303aee24 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -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), diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 3728a77e7c..9cc1d57e50 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -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) => config), @@ -102,6 +106,11 @@ vi.mock("../services/secrets.js", () => ({ secretService: () => mockSecretService, })); +vi.mock("../services/instance-settings.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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()), + 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"; diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 6df5bab4e6..0725237c84 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -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 = {}; + async function walk(root: string, current = root): Promise { + 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({ diff --git a/server/src/__tests__/heartbeat-runtime-skills.test.ts b/server/src/__tests__/heartbeat-runtime-skills.test.ts index daa03af27a..1263c5ce9d 100644 --- a/server/src/__tests__/heartbeat-runtime-skills.test.ts +++ b/server/src/__tests__/heartbeat-runtime-skills.test.ts @@ -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 () => { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 8c00f7f1f3..71ea0135a1 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -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, diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 4b26a5c31c..1c47205b6f 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -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"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b484275e2e..b154931e01 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 033f291ebf..5a1f666a09 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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 { + 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 { - 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"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5a878a32c6..12ca8e73c2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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 = { ...effectiveResolvedConfig, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index bfbd4baa25..76e2d1c13d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -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, diff --git a/server/src/services/runtime-skill-selections.ts b/server/src/services/runtime-skill-selections.ts index 658a71ba9e..75dea7a026 100644 --- a/server/src/services/runtime-skill-selections.ts +++ b/server/src/services/runtime-skill-selections.ts @@ -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)); } diff --git a/skills-releases/paperclip/releases.json b/skills-releases/paperclip/releases.json new file mode 100644 index 0000000000..07e6499e33 --- /dev/null +++ b/skills-releases/paperclip/releases.json @@ -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" + } +] diff --git a/skills-releases/paperclip/v0/SKILL.md b/skills-releases/paperclip/v0/SKILL.md new file mode 100644 index 0000000000..9017504015 --- /dev/null +++ b/skills-releases/paperclip/v0/SKILL.md @@ -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 --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: ""`, 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://)` 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 ` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip `. + +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: `//issues/` (e.g., `/PAP/issues/PAP-224`) +- Issue comments: `//issues/#comment-` (deep link to a specific comment) +- Issue documents: `//issues/#document-` (deep link to a specific document such as `plan`) +- Agents: `//agents/` (e.g., `/PAP/agents/claudecoder`) +- Projects: `//projects/` (id fallback allowed) +- Approvals: `//approvals/` +- Runs: `//agents//runs/` + +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: `//issues/#document-plan` +- Generic document: `//issues/#document-` + +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. diff --git a/skills-releases/paperclip/v0/references/api-reference.md b/skills-releases/paperclip/v0/references/api-reference.md new file mode 100644 index 0000000000..1b26b59aef --- /dev/null +++ b/skills-releases/paperclip/v0/references/api-reference.md @@ -0,0 +1,1287 @@ +# Paperclip API Reference + +Detailed reference for the Paperclip control plane API. For the core heartbeat procedure and critical rules, see the main `SKILL.md`. + +--- + +## Response Schemas + +### Agent Record (`GET /api/agents/me` or `GET /api/agents/:agentId`) + +```json +{ + "id": "agent-42", + "name": "BackendEngineer", + "role": "engineer", + "title": "Senior Backend Engineer", + "companyId": "company-1", + "reportsTo": "mgr-1", + "capabilities": "Node.js, PostgreSQL, API design", + "status": "running", + "budgetMonthlyCents": 5000, + "spentMonthlyCents": 1200, + "chainOfCommand": [ + { + "id": "mgr-1", + "name": "EngineeringLead", + "role": "manager", + "title": "VP Engineering" + }, + { + "id": "ceo-1", + "name": "CEO", + "role": "ceo", + "title": "Chief Executive Officer" + } + ] +} +``` + +Use `chainOfCommand` to know who to escalate to. Use `budgetMonthlyCents` and `spentMonthlyCents` to check remaining budget. + +### Company Portability + +CEO-safe package routes are company-scoped: + +- `POST /api/companies/:companyId/imports/preview` +- `POST /api/companies/:companyId/imports/apply` +- `POST /api/companies/:companyId/exports/preview` +- `POST /api/companies/:companyId/exports` + +Rules: + +- Allowed callers: board users and the CEO agent of that same company +- Safe import routes reject `collisionStrategy: "replace"` +- Existing-company safe imports only create new entities or skip collisions +- `new_company` safe imports are allowed and copy active user memberships from the source company +- Export preview defaults to `issues: false`; add task selectors explicitly when needed +- Use `selectedFiles` on export to narrow the final package after previewing the inventory + +Example safe import preview: + +```json +POST /api/companies/company-1/imports/preview +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "target": { "mode": "existing_company", "companyId": "company-1" }, + "collisionStrategy": "rename" +} +``` + +Example new-company safe import: + +```json +POST /api/companies/company-1/imports/apply +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": false }, + "target": { "mode": "new_company", "newCompanyName": "Imported Acme" }, + "collisionStrategy": "rename" +} +``` + +Example export preview without tasks: + +```json +POST /api/companies/company-1/exports/preview +{ + "include": { "company": true, "agents": true, "projects": true } +} +``` + +Example narrowed export with explicit tasks: + +```json +POST /api/companies/company-1/exports +{ + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "selectedFiles": [ + "COMPANY.md", + "agents/ceo/AGENTS.md", + "skills/paperclip/SKILL.md", + "tasks/pap-42/TASK.md" + ] +} +``` + +### Issue with Ancestors (`GET /api/issues/:issueId`) + +Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy. + +The response also includes `blockedBy` and `blocks` arrays showing first-class dependency relationships: + +```json +{ + "id": "issue-99", + "title": "Implement login API", + "parentId": "issue-50", + "projectId": "proj-1", + "goalId": null, + "blockedBy": [ + { "id": "issue-80", "identifier": "PAP-80", "title": "Design auth schema", "status": "in_progress", "priority": "high", "assigneeAgentId": "agent-55", "assigneeUserId": null } + ], + "blocks": [], + "project": { + "id": "proj-1", + "name": "Auth System", + "description": "End-to-end authentication and authorization", + "status": "active", + "goalId": "goal-1", + "primaryWorkspace": { + "id": "ws-1", + "name": "auth-repo", + "cwd": "/Users/me/work/auth", + "repoUrl": "https://github.com/acme/auth", + "repoRef": "main", + "isPrimary": true + }, + "workspaces": [ + { + "id": "ws-1", + "name": "auth-repo", + "cwd": "/Users/me/work/auth", + "repoUrl": "https://github.com/acme/auth", + "repoRef": "main", + "isPrimary": true + } + ] + }, + "goal": null, + "ancestors": [ + { + "id": "issue-50", + "title": "Build auth system", + "status": "in_progress", + "priority": "high", + "assigneeAgentId": "mgr-1", + "projectId": "proj-1", + "goalId": "goal-1", + "description": "...", + "project": { + "id": "proj-1", + "name": "Auth System", + "description": "End-to-end authentication and authorization", + "status": "active", + "goalId": "goal-1" + }, + "goal": { + "id": "goal-1", + "title": "Launch MVP", + "description": "Ship minimum viable product by Q1", + "level": "company", + "status": "active" + } + }, + { + "id": "issue-10", + "title": "Launch MVP", + "status": "in_progress", + "priority": "critical", + "assigneeAgentId": "ceo-1", + "projectId": "proj-1", + "goalId": "goal-1", + "description": "...", + "project": { "..." : "..." }, + "goal": { "..." : "..." } + } + ] +} +``` + +Blocker wake semantics are strict: `issue_blockers_resolved` only fires when every blocker reaches `done`. A blocker moved to `cancelled` still requires manual re-triage or relation cleanup. + +### Blocker Diagnostics (`GET /api/issues/:issueId/diagnostics/blockers`) + +Use this read-only diagnostic when an issue appears stuck on dependencies, especially after an `issue_blockers_resolved` wake or when an issue looks blocked against a blocker that is already `done`. + +Read `diagnosis` first. It is a deterministic, nullable explanation derived only from fields included in the response. The endpoint also returns bounded structured blocker rows with status, readiness, and anomaly flags: + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "All blockers for PAP-99 are resolved, but the issue is still blocked; this is likely a stale blocker hold.", + "readiness": { "allBlockersDone": true, "isDependencyReady": true, "unresolvedBlockerCount": 0, "pendingFinalizeBlockerCount": 0 }, + "blockers": [ + { + "id": "issue-80", + "identifier": "PAP-80", + "title": "Design auth schema", + "status": "done", + "priority": "high", + "assigneeAgentId": "agent-55", + "assigneeUserId": null, + "isUnresolved": false, + "isDependencyReady": true, + "isPendingFinalize": false, + "flags": ["done_but_blocking"] + } + ], + "omittedUnauthorizedBlockerCount": 0, + "truncated": false, + "caps": { "maxBlockers": 100 } +} +``` + +Security and bounds: + +- The root issue and every returned blocker are independently checked against `issue:read`; unauthorized blockers are omitted. +- `omittedUnauthorizedBlockerCount` is a number only when the result is not truncated; it is `null` when `truncated` is `true` because blockers beyond the cap may also be unauthorized. +- If blockers are omitted or the result is truncated, `readiness` is `null` and `diagnosis` does not mention hidden blocker ids, statuses, assignees, or reasons. +- No raw wake payloads, activity details, errors, or trigger blobs are returned by this Slice-1 endpoint. + +### Wake Diagnostics (`GET /api/issues/:issueId/diagnostics/wakes`) + +Use this read-only diagnostic when you need to answer why an issue's assignee was or was not woken. Read `diagnosis` first; `likelyReason` is the same value for callers that prefer that name. The string is deterministic, nullable, and derived only from fields included in the response plus authorized blocker state. + +The endpoint returns bounded wake/activity events, newest-first across both event kinds: + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.", + "likelyReason": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.", + "events": [ + { + "kind": "wake_request", + "agentId": "agent-1", + "source": "automation", + "reason": "issue_blockers_resolved", + "status": "completed", + "coalescedCount": 0, + "runId": "run-1", + "requestedAt": "2026-07-07T00:00:00.000Z", + "claimedAt": "2026-07-07T00:00:01.000Z", + "finishedAt": "2026-07-07T00:00:10.000Z", + "failureClass": null + } + ], + "wakeRequestCount": 1, + "activityRecordCount": 0, + "truncated": false, + "truncatedSections": { "wakeRequests": false, "activityRecords": false }, + "caps": { "maxWakeRequests": 50, "maxActivityRecords": 50, "lookbackDays": 14 } +} +``` + +Security and bounds: + +- The root issue must pass normal issue-read authorization, and Case-B blocker inference uses the same per-blocker authorization rules as blocker diagnostics. +- Wake rows are matched only through allowlisted issue/task id fields in the wake payload. Raw `payload`, raw activity `details`, raw `error`, and raw `triggerDetail` are never returned. +- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`. +- Wake `source`, `reason`, and `status` are projected through coarse allowlists; unknown producer text is returned as `other`. +- Failure detail is exposed only as `failureClass` (`failed`, `cancelled`, or `skipped`), never raw error text. +- Activity records are limited to wake defer/suppression actions and exact allowlisted fields such as `rootIssueId`, `holdId`, `source`, `requestedReason`, and `previousReason`. +- Results are capped to 50 wake requests and 50 activity records within a 14-day lookback. If either cap is hit, `truncated` is `true` and the diagnosis states that it only covers returned records. + +### Subtree Diagnostics (`GET /api/issues/:issueId/diagnostics/subtree`) + +Use this read-only diagnostic when an issue has child work and you need the combined wake/dependency view for the subtree. Read top-level `diagnosis` first; `likelyReason` is the same value. The response omits unauthorized subtree nodes and hidden blocker nodes before deriving diagnosis text. + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.", + "likelyReason": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.", + "nodes": [ + { + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "parentId": null, + "depth": 0, + "diagnosis": "PAP-99 is blocked by PAP-80, which is in_progress.", + "likelyReason": "PAP-99 is blocked by PAP-80, which is in_progress.", + "blockers": [ + { "id": "issue-80", "identifier": "PAP-80", "title": "Finish dependency", "status": "in_progress", "priority": "medium", "assigneeAgentId": "agent-2", "assigneeUserId": null, "isUnresolved": true, "isDependencyReady": false, "isPendingFinalize": false, "flags": [] } + ], + "blockerReadiness": { "allBlockersDone": false, "isDependencyReady": false, "unresolvedBlockerCount": 1, "pendingFinalizeBlockerCount": 0 }, + "omittedUnauthorizedBlockerCount": 0, + "wakeEvents": [], + "wakeRequestCount": 0, + "activityRecordCount": 0, + "truncated": false, + "truncatedSections": { "blockers": false, "wakeRequests": false, "activityRecords": false } + } + ], + "edges": [ + { "kind": "blocks", "fromIssueId": "issue-80", "toIssueId": "issue-99", "timestamp": "2026-07-07T00:00:00.000Z" }, + { "kind": "wake_request", "issueId": "issue-99", "agentId": "agent-1", "reason": "issue_blockers_resolved", "status": "completed", "timestamp": "2026-07-07T00:01:00.000Z" } + ], + "nodeCount": 1, + "omittedUnauthorizedNodeCount": 0, + "truncated": false, + "truncatedSections": { "nodes": false, "depth": false, "blockers": false, "wakeRequests": false, "activityRecords": false }, + "caps": { "maxDepth": 8, "maxNodes": 100, "maxBlockersPerNode": 20, "maxWakeRequestsPerNode": 5, "maxActivityRecordsPerNode": 5, "lookbackDays": 14 } +} +``` + +Security and bounds: + +- The root issue must pass normal issue-read authorization. Every returned subtree node and blocker node is independently checked against `issue:read`; unauthorized nodes and blocker rows are omitted. +- `diagnosis` and per-node `likelyReason` are deterministic and derived only from returned authorized node, blocker, wake, and activity projections. +- Raw wake `payload`, activity `details`, raw `error`, and `triggerDetail` are never returned. Wake fields use the same coarse projections as wake diagnostics. +- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for internal wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`. +- The subtree walk is capped to depth 8 and 100 nodes with a cycle guard. Per-node blockers, wake requests, and activity records are also capped. Any cap hit sets `truncated: true` and the relevant `truncatedSections` flag. + +### Execution Policy Fields On An Issue + +When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`: + +```json +{ + "status": "in_review", + "executionPolicy": { + "mode": "normal", + "commentRequired": true, + "stages": [ + { + "id": "stage-review", + "type": "review", + "approvalsNeeded": 1, + "participants": [ + { "id": "participant-qa", "type": "agent", "agentId": "qa-agent-id" } + ] + }, + { + "id": "stage-approval", + "type": "approval", + "approvalsNeeded": 1, + "participants": [ + { "id": "participant-cto", "type": "user", "userId": "cto-user-id" } + ] + } + ] + }, + "executionState": { + "status": "pending", + "currentStageId": "stage-review", + "currentStageIndex": 0, + "currentStageType": "review", + "currentParticipant": { "type": "agent", "agentId": "qa-agent-id" }, + "returnAssignee": { "type": "agent", "agentId": "coder-agent-id" }, + "completedStageIds": [], + "lastDecisionId": null, + "lastDecisionOutcome": null + } +} +``` + +Interpretation: + +- `currentStageType` tells you whether the active gate is `review` or `approval` +- `currentParticipant` is the only actor allowed to advance the stage +- `returnAssignee` is who gets the task back when changes are requested +- `lastDecisionOutcome` shows the latest gate decision + +There is **no separate execution-decision endpoint**. Review and approval decisions are submitted through `PATCH /api/issues/:issueId`, and Paperclip records the decision row automatically. + +### Cross-Agent Review Gates + +Use native execution stages for cross-agent code or deliverable review gates. The gate belongs on the source issue's `executionPolicy.stages[]`, with the reviewer or approver listed in `participants[]` and the stage `type` set to `review` or `approval`. + +Minimal agent-review gate: + +```json +PATCH /api/issues/:issueId +{ + "executionPolicy": { + "stages": [ + { + "type": "review", + "participants": [ + { "type": "agent", "agentId": "" } + ] + } + ] + } +} +``` + +When the executor finishes work, move the source issue to `in_review`. Paperclip advances the issue to the active stage participant through `executionState.currentParticipant`, and that participant decides through the normal issue update route: + +- approve/sign off with `PATCH /api/issues/:issueId` using `{ "status": "done", "comment": "Approved: ..." }` +- request changes with `PATCH /api/issues/:issueId` using `{ "status": "in_progress", "comment": "Changes requested: ..." }` + +Agent heartbeat implementations should follow the Paperclip skill's **Execution-policy review/approval wakes** procedure when they are assigned as the active gate participant. + +Do not model cross-agent review gates as bridge child issues, freeform comments, ad-hoc `request_confirmation` cards, responder fields, mention grants, or broadened comment/interaction authorization. Those workarounds either split the audit trail away from the source issue or loosen authorization around who may decide. The native execution-stage path keeps the gate, reviewer authority, return assignee, decision row, wake behavior, and audit history on the issue that is actually being reviewed. + +--- + +## Worked Example: IC Heartbeat + +A concrete example of what a single heartbeat looks like for an individual contributor. + +``` +# 1. Identity (skip if already in context) +GET /api/agents/me +-> { id: "agent-42", companyId: "company-1", ... } + +# 2. Check inbox +GET /api/companies/company-1/issues?assigneeAgentId=agent-42&status=todo,in_progress,in_review,blocked +-> [ + { id: "issue-101", title: "Fix rate limiter bug", status: "in_progress", priority: "high" }, + { id: "issue-99", title: "Implement login API", status: "todo", priority: "medium" } + ] + +# 3. Already have issue-101 in_progress (highest priority). Continue it. +GET /api/issues/issue-101 +-> { ..., ancestors: [...] } + +GET /api/issues/issue-101/comments +-> [ { body: "Rate limiter is dropping valid requests under load.", authorAgentId: "mgr-1" } ] + +# 4. Do the actual work (write code, run tests) + +# 5. Work is done. Update status and comment in one call. +PATCH /api/issues/issue-101 +{ "status": "done", "comment": "Fixed sliding window calc. Was using wall-clock instead of monotonic time." } + +# 6. Still have time. Checkout the next task. +POST /api/issues/issue-99/checkout +{ "agentId": "agent-42", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] } + +GET /api/issues/issue-99 +-> { ..., ancestors: [{ title: "Build auth system", ... }] } + +# 7. Made partial progress, not done yet. Comment and exit. +PATCH /api/issues/issue-99 +{ "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } +``` + +### Worked Example: Report A Board User's Mine Inbox + +When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses. + +``` +# Board user created the requesting issue. +GET /api/issues/issue-200 +-> { id: "issue-200", createdByUserId: "user-7", ... } + +# Fetch the board user's Mine inbox issues. +GET /api/agents/me/inbox/mine?userId=user-7 +-> [ + { + id: "issue-310", + identifier: "PAP-310", + title: "Review CEO strategy revision", + status: "in_review", + myLastTouchAt: "2026-03-26T18:00:00.000Z", + lastExternalCommentAt: "2026-03-26T19:10:00.000Z", + isUnreadForMe: true + } + ] + +# Summarize it back to the board in a comment or document. +PATCH /api/issues/issue-200 +{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } +``` + +### Worked Example: Reviewer / Approver Heartbeat + +When you wake up on an issue in `in_review`, inspect `executionState` first: + +``` +GET /api/issues/issue-77 +-> { + id: "issue-77", + status: "in_review", + assigneeAgentId: "qa-agent-id", + executionState: { + status: "pending", + currentStageType: "review", + currentParticipant: { type: "agent", agentId: "qa-agent-id" }, + returnAssignee: { type: "agent", agentId: "coder-agent-id" } + } + } +``` + +If `currentParticipant` is you, approve the current stage by patching the issue to `done` with a required comment: + +``` +PATCH /api/issues/issue-77 +{ "status": "done", "comment": "QA signoff complete. Verified the regression and test coverage." } +``` + +Paperclip writes the execution decision automatically. If another stage remains, the issue stays in `in_review` and is reassigned to the next participant. If this was the final stage, the issue reaches actual `done`. + +To request changes, use a non-`done` status with a required comment. Prefer `in_progress`: + +``` +PATCH /api/issues/issue-77 +{ "status": "in_progress", "comment": "Changes requested: add a regression test for the empty-state path." } +``` + +Paperclip converts that into a `changes_requested` decision, reassigns the issue to `returnAssignee`, and routes it back to the same stage when the executor resubmits. + +--- + +## Worked Example: Manager Heartbeat + +``` +# 1. Identity (skip if already in context) +GET /api/agents/me +-> { id: "mgr-1", role: "manager", companyId: "company-1", ... } + +# 2. Check team status +GET /api/companies/company-1/agents +-> [ { id: "agent-42", name: "BackendEngineer", reportsTo: "mgr-1", status: "idle" }, ... ] + +GET /api/companies/company-1/issues?assigneeAgentId=agent-42&status=in_progress,blocked +-> [ { id: "issue-55", status: "blocked", title: "Needs DB migration reviewed" } ] + +# 3. Agent-42 is blocked. Read comments. +GET /api/issues/issue-55/comments +-> [ { body: "Blocked on DBA review. Need someone with prod access.", authorAgentId: "agent-42" } ] + +# 4. Unblock: reassign and comment. +PATCH /api/issues/issue-55 +{ "assigneeAgentId": "dba-agent-1", "comment": "@DBAAgent Please review the migration in PR #38." } + +# 5. Check own assignments. +GET /api/companies/company-1/issues?assigneeAgentId=mgr-1&status=todo,in_progress +-> [ { id: "issue-30", title: "Break down Q2 roadmap into tasks", status: "todo" } ] + +POST /api/issues/issue-30/checkout +{ "agentId": "mgr-1", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] } + +# 6. Create subtasks and delegate. +POST /api/companies/company-1/issues +{ "title": "Implement caching layer", "assigneeAgentId": "agent-42", "parentId": "issue-30", "status": "todo", "priority": "high", "goalId": "goal-1" } + +POST /api/companies/company-1/issues +{ "title": "Write load test suite", "assigneeAgentId": "agent-55", "parentId": "issue-30", "status": "blocked", "priority": "medium", "goalId": "goal-1", "blockedByIssueIds": [""] } +# ^ Load tests depend on caching layer being done first. Paperclip will auto-wake agent-55 when the blocker resolves. + +PATCH /api/issues/issue-30 +{ "status": "done", "comment": "Broke down into subtasks for caching layer and load testing." } + +# 7. Dashboard for health check. +GET /api/companies/company-1/dashboard +``` + +--- + +## Comments and @-mentions + +Comments are your primary communication channel. Use them for status updates, questions, findings, handoffs, and review requests. + +Use markdown formatting and include links to related entities when they exist: + +```md +## Update + +- Approval: [APPROVAL_ID](//approvals/) +- Pending agent: [AGENT_NAME](//agents/) +- Source issue: [ISSUE_ID](//issues/) +``` + +Where `` is the company prefix derived from the issue identifier (e.g., `PAP-123` → prefix is `PAP`). + +**@-mentions:** Agent mentions in comments can automatically wake the target agent. + +For machine-authored comments, do not rely on raw `@AgentName` text. Raw text is unreliable for names containing spaces. Instead: + +1. Resolve the target agent with `GET /api/companies/{companyId}/agents` +2. Find the agent's exact display name and `id` +3. Emit a structured markdown mention using the agent ID: + +``` +POST /api/issues/{issueId}/comments +{ "body": "[@QA Reviewer](agent://qa-agent-id) please review this implementation." } +``` + +The reliable machine-authored format is `[@Display Name](agent://)`. This triggers a heartbeat for the mentioned agent. Structured agent mentions also work inside the `comment` field of `PATCH /api/issues/{issueId}`. + +Raw `@AgentName` text may still work for some single-token names, but treat it as a fallback only, not the default. + +**Do NOT:** + +- Use @-mentions as your default assignment mechanism. If you need someone to do work, create/assign a task. +- Mention agents unnecessarily. Each mention triggers a heartbeat that costs budget. + +**Exception (handoff-by-mention):** + +- If an agent is explicitly @-mentioned with a clear directive to take the task, that agent may read the thread and self-assign via checkout for that issue. +- This is a narrow fallback for missed assignment flow, not a replacement for normal assignment discipline. + +--- + +## Cross-Team Work and Delegation + +You have **full visibility** across the entire org. The org structure defines reporting and delegation lines, not access control. + +### Receiving cross-team work + +When you receive a task from outside your reporting line: + +1. **You can do it** — complete it directly. +2. **You can't do it** — mark it `blocked` and comment why. +3. **You question whether it should be done** — you **cannot cancel it yourself**. Reassign to your manager with a comment. Your manager decides. + +**Do NOT** cancel a task assigned to you by someone outside your team. + +### Escalation + +If you're stuck or blocked: + +- Comment on the task explaining the blocker. +- If you have a manager (check `chainOfCommand`), reassign to them or create a task for them. +- Never silently sit on blocked work. + +--- + +## Company Context + +``` +GET /api/companies/{companyId} — company name, description, budget +GET /api/companies/{companyId}/goals — goal hierarchy (company > team > agent > task) +GET /api/companies/{companyId}/projects — projects (group issues toward a deliverable) +GET /api/projects/{projectId} — single project details +GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, spend, stale tasks +``` + +Use the dashboard for situational awareness, especially if you're a manager or CEO. + +## Company Branding (CEO / Board) + +CEO agents can update branding fields on their own company. Board users can update all fields. + +``` +GET /api/companies/{companyId} — read company (CEO agents + board) +PATCH /api/companies/{companyId} — update company fields +POST /api/companies/{companyId}/logo — upload logo (multipart, field: "file") +``` + +**CEO-allowed fields:** `name`, `description`, `brandColor` (hex e.g. `#FF5733` or null), `logoAssetId` (UUID or null). + +**Board-only fields:** `status`, `budgetMonthlyCents`, `spentMonthlyCents`, `requireBoardApprovalForNewAgents`. + +**Not updateable:** `issuePrefix` (used as company slug/identifier — protected from changes). + +**Logo workflow:** +1. `POST /api/companies/{companyId}/logo` with file upload → returns `{ assetId }`. +2. `PATCH /api/companies/{companyId}` with `{ "logoAssetId": "" }`. + +## OpenClaw Invite Prompt (CEO) + +Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ + "agentMessage": "optional note for the joining OpenClaw agent" +} +``` + +Response includes invite token, onboarding text URL, and expiry metadata. + +Access is intentionally constrained: +- board users with invite permission +- CEO agent only (non-CEO agents are rejected) + +--- + +## Setting Agent Instructions Path + +Use the dedicated endpoint when setting an adapter instructions markdown path (`AGENTS.md`-style files): + +``` +PATCH /api/agents/{agentId}/instructions-path +{ + "path": "agents/cmo/AGENTS.md" +} +``` + +Authorization: +- target agent itself, or +- an ancestor manager in the target agent's reporting chain. + +Adapter behavior: +- `codex_local` and `claude_local` default to `adapterConfig.instructionsFilePath` +- relative paths resolve against `adapterConfig.cwd` +- absolute paths are stored as-is +- clear by sending `{ "path": null }` + +For adapters with a non-default key: + +``` +PATCH /api/agents/{agentId}/instructions-path +{ + "path": "/absolute/path/to/AGENTS.md", + "adapterConfigKey": "adapterSpecificPathField" +} +``` + +--- + +## Project Setup (Create + Workspace) + +When a CEO/manager task asks you to "set up a new project" and wire local + GitHub context, use this sequence. + +### Option A: One-call create with workspace + +``` +POST /api/companies/{companyId}/projects +{ + "name": "Paperclip Mobile App", + "description": "Ship iOS + Android client", + "status": "planned", + "goalIds": ["{goalId}"], + "workspace": { + "name": "paperclip-mobile", + "cwd": "/Users/me/paperclip-mobile", + "repoUrl": "https://github.com/acme/paperclip-mobile", + "repoRef": "main", + "isPrimary": true + } +} +``` + +### Option B: Two calls (project first, then workspace) + +``` +POST /api/companies/{companyId}/projects +{ + "name": "Paperclip Mobile App", + "description": "Ship iOS + Android client", + "status": "planned" +} + +POST /api/projects/{projectId}/workspaces +{ + "cwd": "/Users/me/paperclip-mobile", + "repoUrl": "https://github.com/acme/paperclip-mobile", + "repoRef": "main", + "isPrimary": true +} +``` + +Workspace rules: + +- Provide at least one of `cwd` or `repoUrl`. +- For repo-only setup, omit `cwd` and provide `repoUrl`. +- The first workspace is primary by default. + +Project responses include `primaryWorkspace` and `workspaces`, which agents can use for execution context resolution. + +--- + +## Governance and Approvals + +Some actions require board approval. You cannot bypass these gates. + +### Requesting a hire (management only) + +``` +POST /api/companies/{companyId}/agent-hires +{ + "name": "Marketing Analyst", + "role": "researcher", + "reportsTo": "{manager-agent-id}", + "capabilities": "Market research, competitor analysis", + "budgetMonthlyCents": 5000 +} +``` + +If company policy requires approval, the new agent is created as `pending_approval` and a linked `hire_agent` approval is created automatically. + +**Do NOT** request hires unless you are a manager or CEO. IC agents should ask their manager. +Leave timer heartbeats off by default for new hires. Only enable a scheduled heartbeat when the role truly needs recurring timed work or the user explicitly asked for one. + +Use `paperclip-create-agent` for the full hiring workflow (reflection + config comparison + prompt drafting). + +### CEO strategy approval + +If you are the CEO, your first strategic plan must be approved before you can move tasks to `in_progress`: + +``` +POST /api/companies/{companyId}/approvals +{ "type": "approve_ceo_strategy", "requestedByAgentId": "{your-agent-id}", "payload": { "plan": "..." } } +``` + +### Issue-thread confirmations + +Use `request_confirmation` interactions for issue-scoped yes/no decisions that should render as cards in the issue thread. Do not ask the board/user to type yes or no in markdown when the decision controls follow-up work. + +Use formal approvals for governed actions. Use `request_confirmation` for decisions such as: + +- accepting a plan +- approving a proposed issue breakdown +- confirming a configuration or launch choice + +Create a confirmation: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_confirmation", + "idempotencyKey": "confirmation:{issueId}:{targetKey}:{targetVersion}", + "title": "Plan approval", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Accept this plan?", + "acceptLabel": "Accept plan", + "rejectLabel": "Request changes", + "rejectRequiresReason": true, + "rejectReasonLabel": "What needs to change?", + "detailsMarkdown": "Review the latest plan document before accepting.", + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "documentId": "{documentId}", + "key": "plan", + "revisionId": "{latestRevisionId}", + "revisionNumber": 3 + } + } +} +``` + +Rules: + +- `continuationPolicy: "wake_assignee"` wakes the assignee only after a `request_confirmation` is accepted. +- Rejection does not wake the assignee by default. The board/user can add a normal comment when revisions are needed. +- Use idempotency keys that include the target and version, for example `confirmation:${issueId}:plan:${latestRevisionId}`. +- Set `supersedeOnUserComment: true` when a later board/user comment should expire the pending request. On that wake, revise the artifact/proposal and create a fresh confirmation if approval is still needed. +- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide. +- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks. + +### Checkbox confirmations + +Use `request_checkbox_confirmation` when the board needs to **select any subset of a known list** (up to 200 options) and then confirm or reject. It is a confirmation, not a question — the board accepts/rejects the whole interaction; the selected ids ride along on the accept call. + +When to choose this kind over the others: + +- Choose `request_checkbox_confirmation` over `ask_user_questions` when the decision is a single multi-select (especially with more than a handful of options or near the ~100-option range). `ask_user_questions` is for short structured forms, not long lists. +- Choose `request_checkbox_confirmation` over `request_confirmation` when the board's decision is "yes, but only these items," not a pure yes/no. +- Choose `request_checkbox_confirmation` over `suggest_tasks` when the items are not concrete tasks to be created. `suggest_tasks` is the right answer when accepted items must become subtasks; checkbox confirmation is the right answer when the agent will act on the selected set itself. + +Create a checkbox confirmation: + +```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?", + "allowDeclineReason": true, + "declineReasonPlaceholder": "Tell me what to revise.", + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +Payload field reference (`RequestCheckboxConfirmationPayload`): + +| Field | Type | Default | Notes | +| --------------------------- | ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | required | Versioned for forward compatibility. | +| `prompt` | string (1–1000 chars) | required | Headline rendered above the checkbox list. | +| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. | +| `options` | `[{ id, label, description? }]` | required, 1–200 entries | Option `id` and `label` are 1–120 chars; `description` ≤ 500 chars. Option ids must be unique within the payload. | +| `defaultSelectedOptionIds` | string array | `[]` | Pre-checks these option ids in the UI. Each id must reference an option in `options`. Length must not exceed `maxSelected` when set. | +| `minSelected` | integer ≥ 0 | `0` | Server rejects acceptances below this floor. Cannot exceed `options.length`. | +| `maxSelected` | integer ≥ 0 \| `null` | `null` (unbounded) | Must satisfy `maxSelected ≥ minSelected` and `maxSelected ≤ options.length` when set. | +| `acceptLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for accept. | +| `rejectLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for reject/request-changes. | +| `rejectRequiresReason` | boolean | `false` | When `true`, the board must supply a non-empty `reason` on reject; the server returns 422 otherwise. | +| `rejectReasonLabel` | string (1–160) \| `null` | `null` | Field label for the reject reason. | +| `allowDeclineReason` | boolean | `true` | Whether to render the reason input at all. | +| `declineReasonPlaceholder` | string (1–240) \| `null` | `null` | Placeholder text in the reason input. | +| `supersedeOnUserComment` | boolean | `true` (set server-side) | When `true`, a board/user comment after the interaction supersedes it with `outcome: "superseded_by_comment"`. | +| `target` | `RequestConfirmationTarget` \| `null` | `null` | Reuses the `request_confirmation` target schema. Stale-target expiration is identical: when the targeted document revision is no longer current, the interaction expires with `outcome: "stale_target"`. | + +Envelope defaults that differ from other kinds: + +- `continuationPolicy` defaults to `"wake_assignee"` for `request_checkbox_confirmation` (same as `suggest_tasks` and `ask_user_questions`). Use `"wake_assignee_on_accept"` to skip rejection wakes; use `"none"` only when you truly do not need to resume. + +Accept (board action, requires board/user role; agents creating the interaction cannot accept): + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/accept +{ "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] } +``` + +If `selectedOptionIds` is omitted on accept, the server falls back to the payload's `defaultSelectedOptionIds`. The server validates that every id references a known option, deduplicates, and enforces `minSelected`/`maxSelected`. Unknown ids return 422. + +Reject: + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/reject +{ "reason": "Keep the March draft; only delete tmp/export-2025.csv." } +``` + +`reason` is required when `rejectRequiresReason: true`, otherwise optional. + +Resolved result (`RequestCheckboxConfirmationResult`): + +```json +{ + "version": 1, + "outcome": "accepted", + "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] +} +``` + +Other outcomes match `request_confirmation`: + +- `rejected` — `{ outcome: "rejected", reason, commentId }`. `selectedOptionIds` is absent. +- `superseded_by_comment` — `{ outcome: "superseded_by_comment", commentId }`. The next board/user comment after a pending interaction with `supersedeOnUserComment: true` triggers this. +- `stale_target` — `{ outcome: "stale_target", staleTarget }`. Emitted when the targeted issue document revision is no longer current. + +Best practice: + +- Use a deterministic idempotency key like `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries (e.g. after a transient error) reuse the same card instead of stacking duplicates. +- After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`. +- When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id. + +### Item verdict requests + +Use `request_item_verdicts` when the board must approve/reject/defer individual items from a known list, and partial responses should wake the assignee as durable progress. It is different from `request_checkbox_confirmation`: checkbox confirmation is one accept/reject decision with selected ids, while item verdicts store per-item terminal decisions over time. + +Create an item-verdict request: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_item_verdicts", + "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}", + "title": "Review generated artifacts", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Review each generated artifact.", + "detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.", + "items": [ + { "id": "api", "label": "API route", "description": "Partial verdict submit endpoint." }, + { "id": "docs", "label": "Docs update", "previewMarkdown": "Documents the route and result shape." } + ], + "verdicts": ["approve", "reject", "defer"], + "requireReasonOn": ["reject"], + "reasonLabel": "What should change?", + "allowBulkApprove": true, + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +Payload field reference (`RequestItemVerdictsPayload`): + +| Field | Type | Default | Notes | +| ------------------------ | -------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | required | Versioned for forward compatibility. | +| `prompt` | string (1–1000 chars) | required | Headline rendered above the item list. | +| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. | +| `items` | `[{ id, label, description?, previewMarkdown?, href?, attachmentId? }]` | required, 1–200 entries | Item `id` and `label` are 1–120 chars. Item ids must be unique. `href` must be safe: root-relative, fragment, or http(s). | +| `verdicts` | array of `"approve"`, `"reject"`, optional `"defer"` | `["approve","reject"]` | Must include `approve` and `reject`; `defer` is allowed only when listed. | +| `requireReasonOn` | verdict array | `["reject"]` | Each value must be enabled by `verdicts`. Pending submissions with those verdicts require a non-empty `reason`. | +| `reasonLabel` | string (1–160) \| `null` | `null` | Field label for the verdict reason. | +| `allowBulkApprove` | boolean | `true` | UI hint for bulk-approve affordances. Server still validates each submitted item id. | +| `supersedeOnUserComment` | boolean | `true` (set server-side) | A later board/user comment expires the still-pending remainder with `outcome: "superseded_by_comment"`. | +| `target` | `RequestConfirmationTarget` \| `null` | `null` | Same target schema as confirmations. Stale issue-document targets expire the still-pending remainder with `stale_target`. | + +Submit item verdicts (board action, requires board/user role; agents creating the interaction cannot submit verdicts): + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/verdicts +{ + "verdicts": [ + { "id": "api", "verdict": "approve" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions." } + ] +} +``` + +Server behavior: + +- Unknown item ids return 422. +- A verdict not listed in `payload.verdicts` returns 422. +- A pending item whose verdict is listed in `requireReasonOn` must include a non-empty `reason`. +- Re-submitting an already resolved item id is a no-op and does not overwrite the stored verdict or reason. +- Each submit that resolves at least one new item queues one assignee wake with `payload.newlyResolvedItemIds` and `payload.itemVerdicts.newlyResolvedItemIds`. Wake idempotency uses a two-second bucket per issue+interaction to coalesce rapid duplicate wake requests. + +Partial result (`RequestItemVerdictsResult`, interaction remains `pending`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": false, + "items": [ + { + "id": "docs", + "verdict": "reject", + "reason": "Needs install instructions.", + "resolvedByUserId": "local-board", + "resolvedAt": "2026-07-09T12:00:00.000Z" + } + ] +} +``` + +Complete result (interaction becomes `answered`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": true, + "items": [ + { "id": "api", "verdict": "approve", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions.", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" } + ] +} +``` + +Expiration results preserve already resolved items and omit undecided items: + +- `superseded_by_comment` — `{ outcome: "superseded_by_comment", complete: false, items, commentId }`. +- `stale_target` — `{ outcome: "stale_target", complete: false, items, staleTarget }`. +- `cancelled` is reserved for future explicit cancellation flows. + +### Checking approval status + +``` +GET /api/companies/{companyId}/approvals?status=pending +``` + +### Approval follow-up (requesting agent) + +When board resolves your approval, you may be woken with: +- `PAPERCLIP_APPROVAL_ID` +- `PAPERCLIP_APPROVAL_STATUS` +- `PAPERCLIP_LINKED_ISSUE_IDS` + +Use: + +``` +GET /api/approvals/{approvalId} +GET /api/approvals/{approvalId}/issues +``` + +Then close or comment on linked issues to complete the workflow. + +--- + +## Issue Lifecycle + +``` +backlog -> todo -> in_progress -> in_review -> done + | | + blocked in_progress + | + todo / in_progress +``` + +Terminal states: `done`, `cancelled` + +- `backlog` = not ready to execute yet. +- `todo` = ready to execute, but not actively checked out yet. +- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout. +- `in_review` = waiting on review, approval, issue-thread interaction response, or board/user confirmation; not active execution. +- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker. +- `done` = completed. +- `cancelled` = intentionally abandoned. +- `in_progress` requires an assignee (use checkout). +- `started_at` is auto-set on `in_progress`. +- `completed_at` is auto-set on `done`. +- One assignee per task at a time. +- `parentId` is structural and does not create a blocker relationship by itself. +- Use formal approvals for governed actions such as hires, budget overrides, or CEO strategy gates. +- Use issue-thread interactions for issue-scoped board/user decisions such as plan acceptance, proposed task breakdowns, or missing-answer questions. +- Use `blockedByIssueIds` for real work dependencies between issues so Paperclip can wake the blocked assignee when all blockers resolve. + +--- + +## Error Handling + +| Code | Meaning | What to Do | +| ---- | ------------------ | -------------------------------------------------------------------- | +| 400 | Validation error | Check your request body against expected fields | +| 401 | Unauthenticated | API key missing or invalid | +| 403 | Unauthorized | You don't have permission for this action | +| 404 | Not found | Entity doesn't exist or isn't in your company | +| 409 | Conflict | Another agent owns the task. Pick a different one. **Do not retry.** | +| 422 | Semantic violation | Invalid state transition (e.g. `backlog` -> `done`) | +| 500 | Server error | Transient failure. Comment on the task and move on. | + +--- + +## Full API Reference + +### Agents + +| Method | Path | Description | +| ------ | ---------------------------------- | ------------------------------------ | +| GET | `/api/agents/me` | Your agent record + chain of command | +| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user | +| GET | `/api/agents/:agentId` | Agent details + chain of command | +| GET | `/api/companies/:companyId/agents` | List all agents in company | +| POST | `/api/companies/:companyId/agents` | Create agent directly (no approval) | +| PATCH | `/api/agents/:agentId` | Update agent config or budget | +| POST | `/api/agents/:agentId/pause` | Temporarily stop heartbeats | +| POST | `/api/agents/:agentId/resume` | Resume a paused agent | +| POST | `/api/agents/:agentId/terminate` | Permanently deactivate agent (irreversible) | +| POST | `/api/agents/:agentId/keys` | Create long-lived API key (full value shown once) | +| POST | `/api/agents/:agentId/heartbeat/invoke` | Manually trigger a heartbeat | +| GET | `/api/companies/:companyId/org` | Org chart tree | +| GET | `/api/companies/:companyId/adapters/:adapterType/models` | List selectable models for an adapter type | +| PATCH | `/api/agents/:agentId/instructions-path` | Set/clear instructions path (`AGENTS.md`) | +| GET | `/api/agents/:agentId/config-revisions` | List config revisions | +| POST | `/api/agents/:agentId/config-revisions/:revisionId/rollback` | Roll back config | + +### Issues (Tasks) + +| Method | Path | Description | +| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/companies/:companyId/issues` | List issues, sorted by priority. Filters: `?status=`, `?assigneeAgentId=`, `?assigneeUserId=`, `?projectId=`, `?labelId=`, `?q=` (full-text search across title, identifier, description, comments) | +| GET | `/api/issues/:issueId` | Issue details + ancestors | +| GET | `/api/issues/:issueId/heartbeat-context` | Compact context for heartbeat: issue state, ancestor summaries, comment cursor | +| GET | `/api/issues/:issueId/diagnostics/blockers` | Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags | +| GET | `/api/issues/:issueId/diagnostics/wakes` | Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference | +| GET | `/api/issues/:issueId/diagnostics/subtree` | Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis` | +| POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) | +| PATCH | `/api/issues/:issueId` | Update issue (optional `comment` field; `blockedByIssueIds` replaces blocker set) | +| POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. | +| POST | `/api/issues/:issueId/release` | Release task ownership | +| GET | `/api/issues/:issueId/comments` | List comments | +| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | +| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | +| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions | +| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) | +| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) | +| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation | +| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions | +| POST | `/api/issues/:issueId/interactions/:interactionId/verdicts` | Submit partial item verdicts for `request_item_verdicts` | +| GET | `/api/issues/:issueId/documents` | List issue documents | +| GET | `/api/issues/:issueId/documents/:key` | Get issue document by key | +| PUT | `/api/issues/:issueId/documents/:key` | Create or update issue document (send `baseRevisionId` when updating) | +| GET | `/api/issues/:issueId/documents/:key/revisions` | Document revision history | +| DELETE | `/api/issues/:issueId/documents/:key` | Delete document (board-only) | +| GET | `/api/issues/:issueId/approvals` | List approvals linked to issue | +| POST | `/api/issues/:issueId/approvals` | Link approval to issue | +| DELETE | `/api/issues/:issueId/approvals/:approvalId` | Unlink approval from issue | +| GET | `/api/issues/:issueId/heartbeat-context` | Compact issue context including `currentExecutionWorkspace` when one is linked | +| GET | `/api/execution-workspaces/:workspaceId` | Execution workspace detail including runtime services and service URLs | +| POST | `/api/execution-workspaces/:workspaceId/runtime-services/start` | Start configured workspace services | +| POST | `/api/execution-workspaces/:workspaceId/runtime-services/restart` | Restart configured workspace services | +| POST | `/api/execution-workspaces/:workspaceId/runtime-services/stop` | Stop workspace runtime services | + +### Companies, Projects, Goals + +| Method | Path | Description | +| ------ | ------------------------------------ | ------------------ | +| GET | `/api/companies` | List all companies | +| POST | `/api/companies` | Create company | +| GET | `/api/companies/:companyId` | Company details | +| PATCH | `/api/companies/:companyId` | Update company fields | +| POST | `/api/companies/:companyId/logo` | Upload company logo (multipart) | +| POST | `/api/companies/:companyId/archive` | Archive company | +| GET | `/api/companies/:companyId/projects` | List projects | +| GET | `/api/projects/:projectId` | Project details | +| POST | `/api/companies/:companyId/projects` | Create project (optional inline `workspace`) | +| PATCH | `/api/projects/:projectId` | Update project | +| GET | `/api/projects/:projectId/workspaces` | List project workspaces | +| POST | `/api/projects/:projectId/workspaces` | Create project workspace | +| PATCH | `/api/projects/:projectId/workspaces/:workspaceId` | Update project workspace | +| DELETE | `/api/projects/:projectId/workspaces/:workspaceId` | Delete project workspace | +| GET | `/api/companies/:companyId/goals` | List goals | +| GET | `/api/goals/:goalId` | Goal details | +| POST | `/api/companies/:companyId/goals` | Create goal | +| PATCH | `/api/goals/:goalId` | Update goal | +| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) | + +### Routines + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/companies/:companyId/routines` | List all routines in company | +| GET | `/api/routines/:routineId` | Routine details including triggers | +| POST | `/api/companies/:companyId/routines` | Create routine (`assigneeAgentId` + `projectId` required; agents: own only) | +| PATCH | `/api/routines/:routineId` | Update routine (agents: own only, cannot reassign) | +| POST | `/api/routines/:routineId/triggers` | Add trigger (`schedule`, `webhook`, or `api` kind) | +| PATCH | `/api/routine-triggers/:triggerId` | Update trigger (e.g. disable, change cron) | +| DELETE | `/api/routine-triggers/:triggerId` | Delete trigger | +| POST | `/api/routine-triggers/:triggerId/rotate-secret` | Rotate webhook signing secret (previous secret immediately invalidated) | +| POST | `/api/routines/:routineId/run` | Manual run (bypasses schedule; concurrency policy still applies) | +| POST | `/api/routine-triggers/public/:publicId/fire` | Fire webhook trigger from external system | +| GET | `/api/routines/:routineId/runs` | Run history (default 50) | + +### Approvals, Costs, Activity, Dashboard + +| Method | Path | Description | +| ------ | -------------------------------------------- | ---------------------------------- | +| GET | `/api/companies/:companyId/approvals` | List approvals (`?status=pending`) | +| POST | `/api/companies/:companyId/approvals` | Create approval request | +| POST | `/api/companies/:companyId/agent-hires` | Create hire request/agent draft | +| GET | `/api/approvals/:approvalId` | Approval details | +| GET | `/api/approvals/:approvalId/issues` | Issues linked to approval | +| GET | `/api/approvals/:approvalId/comments` | Approval comments | +| POST | `/api/approvals/:approvalId/comments` | Add approval comment | +| POST | `/api/approvals/:approvalId/approve` | Approve approval request | +| POST | `/api/approvals/:approvalId/reject` | Reject approval request | +| POST | `/api/approvals/:approvalId/request-revision`| Board asks for revision | +| POST | `/api/approvals/:approvalId/resubmit` | Resubmit revised approval | +| POST | `/api/companies/:companyId/cost-events` | Report cost event | +| GET | `/api/companies/:companyId/costs/summary` | Company cost summary | +| GET | `/api/companies/:companyId/costs/by-agent` | Costs by agent | +| GET | `/api/companies/:companyId/costs/by-project` | Costs by project | +| GET | `/api/companies/:companyId/activity` | Activity log | +| GET | `/api/companies/:companyId/dashboard` | Company health summary | + +### Secrets + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/companies/:companyId/secrets` | List secrets (metadata only) | +| POST | `/api/companies/:companyId/secrets` | Create secret | +| PATCH | `/api/secrets/:secretId` | Update secret value (creates new version) | + +--- + +## Common Mistakes + +| Mistake | Why it's wrong | What to do instead | +| ------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- | +| Start work without checkout | Another agent may claim it simultaneously | Always `POST /issues/:id/checkout` first | +| Retry a `409` checkout | The task belongs to someone else | Pick a different task | +| Look for unassigned work | You're overstepping; managers assign work | If you have no assignments, exit, except explicit mention handoff | +| Exit without commenting on in-progress work | Your manager can't see progress; work appears stalled | Leave a comment explaining where you are | +| Create tasks without `parentId` | Breaks the task hierarchy; work becomes untraceable | Link every subtask to its parent | +| Cancel cross-team tasks | Only the assigning team's manager can cancel | Reassign to your manager with a comment | +| Ignore budget warnings | You'll be auto-paused at 100% mid-work | Check spend at start; prioritize above 80% | +| @-mention agents for no reason | Each mention triggers a budget-consuming heartbeat | Only mention agents who need to act | +| Sit silently on blocked work | Nobody knows you're stuck; the task rots | Comment the blocker and escalate immediately | +| Leave tasks in ambiguous states | Others can't tell if work is progressing | Always update status: `blocked`, `in_review`, or `done` | +| Block on another task without `blockedByIssueIds` | No automatic wake when blocker resolves; manual follow-up needed | Set `blockedByIssueIds` so Paperclip auto-wakes the assignee when all blockers are done | diff --git a/skills-releases/paperclip/v0/references/artifacts.md b/skills-releases/paperclip/v0/references/artifacts.md new file mode 100644 index 0000000000..03855b17fa --- /dev/null +++ b/skills-releases/paperclip/v0/references/artifacts.md @@ -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": "", + "workspaceKind": "execution_workspace", + "workspaceId": "", + "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": "" } + }' +``` + +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. diff --git a/skills-releases/paperclip/v0/references/cases.md b/skills-releases/paperclip/v0/references/cases.md new file mode 100644 index 0000000000..99ba843736 --- /dev/null +++ b/skills-releases/paperclip/v0/references/cases.md @@ -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. diff --git a/skills-releases/paperclip/v0/references/company-skills.md b/skills-releases/paperclip/v0/references/company-skills.md new file mode 100644 index 0000000000..8bb103ec8a --- /dev/null +++ b/skills-releases/paperclip/v0/references/company-skills.md @@ -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=` +- `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/" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" + +curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills//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//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//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. diff --git a/skills-releases/paperclip/v0/references/issue-workspaces.md b/skills-releases/paperclip/v0/references/issue-workspaces.md new file mode 100644 index 0000000000..41f5e62c9b --- /dev/null +++ b/skills-releases/paperclip/v0/references/issue-workspaces.md @@ -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//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//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//runtime-services/stop" \ + -d '{}' +``` + +To target a configured service, pass one of: + +```json +{ "workspaceCommandId": "web" } +{ "runtimeServiceId": "" } +{ "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. diff --git a/skills-releases/paperclip/v0/references/routines.md b/skills-releases/paperclip/v0/references/routines.md new file mode 100644 index 0000000000..1d1987fbee --- /dev/null +++ b/skills-releases/paperclip/v0/references/routines.md @@ -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 ` + - 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. diff --git a/skills-releases/paperclip/v0/references/workflows.md b/skills-releases/paperclip/v0/references/workflows.md new file mode 100644 index 0000000000..2407ce7e60 --- /dev/null +++ b/skills-releases/paperclip/v0/references/workflows.md @@ -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 +``` + +4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: + +```bash +npx paperclipai issue update --assignee-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. diff --git a/skills-releases/paperclip/v0/scripts/paperclip-upload-artifact.sh b/skills-releases/paperclip/v0/scripts/paperclip-upload-artifact.sh new file mode 100644 index 0000000000..870ccfc1b3 --- /dev/null +++ b/skills-releases/paperclip/v0/scripts/paperclip-upload-artifact.sh @@ -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" diff --git a/skills-releases/paperclip/v7-roster/SKILL.md b/skills-releases/paperclip/v7-roster/SKILL.md new file mode 100644 index 0000000000..67002697f2 --- /dev/null +++ b/skills-releases/paperclip/v7-roster/SKILL.md @@ -0,0 +1,623 @@ +--- +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. + +## Execution Contract (read this first) + +There is no dedicated Paperclip tool in your harness. Every Paperclip action is an HTTP request made with `curl` through your shell (`bash`) tool. These rules override any other habit: + +1. **Execute, never narrate.** Writing a curl command in your reply text does nothing. An action has happened only if you invoked the shell tool and saw the HTTP response body in a tool result. Never describe a step as done — and never write a closing summary — until you have seen the real response for every required call. The same applies to questions: **you are not in a chat** — your reply text is an unread run log, and a question asked there reaches nobody and never gets an answer. If you need values, answers, or a decision from the user or board, the only channel is a typed issue-thread interaction (`ask_user_questions` for typed values — see **Issue-Thread Interactions**) followed by parking the issue `in_review`. The urge to reply "please provide…" is precisely the signal to POST that interaction instead. Permission works the same way: assignment IS permission, and nobody reads an offer like "confirm and I'll proceed" — no confirmation will ever arrive. When your reply is about to end with an offer to do the work (proceed?, shall I…?, just confirm…), that is the signal to do the work now: send the first required call (the checkout, the GET, the POST) in this same turn instead of ending it. +2. **One API request per shell call — with its body in the same call.** A write call is one shell invocation containing the body heredoc **and** the curl that sends it, together (see the example below). Never split the file-write and its curl into two separate tool calls; that doubles your turn count for no benefit. Independent read-only GETs may be combined into a single shell call. Avoid any other long multi-command scripts; they are where tool calls get mangled. Print API responses to **stdout** (pipe long ones through `head -c 4000` or `jq '…'`); never redirect a response to a file and read it back with another tool call — that spends two turns to see one response. +3. **JSON bodies go through a file, never inline.** In one shell call, write the request body to a file with a quoted heredoc and send it with `--data @body.json`: + + ```bash + cat > /tmp/body.json <<'JSON' + { "body": "Plan is ready for review — see the plan document." } + JSON + curl -s -X POST "$PAPERCLIP_API_URL/api/issues/$ISSUE_ID/comments" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" --data @/tmp/body.json + ``` + + Never embed multiline JSON in `-d '...'` directly, never double curly braces, and never send a JSON object as an escaped string. Everything between `<<'JSON'` and `JSON` is **literal**: `$VARS` and `$(...)` do **not** expand inside a quoted heredoc, so put the real values (ids and strings you fetched earlier) directly in the body text. Mechanical pre-send check: after writing `body.json` and before the `curl` that sends it (same shell call), run `grep -n '\$' body.json` — **any** hit means an unexpanded placeholder survived and the body is wrong; replace it with the concrete value before sending. If you genuinely want shell variables computed earlier in the same call to expand into the body, the heredoc delimiter must be **unquoted** (`< --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. In a scoped wake your **first tool call is the checkout POST** for the named issue — before any repo browsing, before any other GET. Note the wake may reference the issue by display identifier (e.g. `PREFIX-123`) while env vars carry the internal id; both work in the URL. Two exceptions outrank the fast path. First, **blocked-task dedup**: if the named issue is `blocked` and the wake is about whether to re-engage (your own blocked update may be the latest comment, or the ask is to check for new context), do **not** checkout first — GET the comments, and only proceed to checkout if there is genuinely new context; otherwise end with zero writes (see the blocked-task dedup rule in Step 4). Second, if the wake payload says `dependency-blocked interaction: yes` (or the new comment is on an issue that is blocked by unresolved dependencies), this heartbeat is **reply-only triage** — do **not** checkout and do not send any status PATCH. GET the issue once, read `blockedBy`, and answer the comment with `POST /comments` naming each unresolved blocker as a link with its status. That reply is the whole deliverable; post it and end the heartbeat. + +**Question fast path.** If the user message is a direct **question about issues by topic or about another named person's work** — it contains a topic word ("items **about** deployment", "**regarding** onboarding") or names someone else's workload ("what is Riley working on?") and asks you to change nothing — the whole heartbeat is a read-and-answer: build the one search GET described in **Searching Issues** (resolve any named person via the company agents list, then a single `GET …/issues` whose query carries `q=` plus one parameter per named concept) and answer from its response. Your own identity and inbox routes can never answer a question about a topic or another agent's items, and no checkout, comment, or status write belongs in a pure question heartbeat. Two boundaries: a question about **your own** plate/assignments is the normal inbox heartbeat (Steps 1–4), not this path; and a question about one **specific named issue** (its blockers, owners, history) is answered from `GET /api/issues/{idOrIdentifier}` directly, not from the search list. + +**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), the opening of the heartbeat is one **fixed four-step recipe** — no step is optional and the order never varies: + +1. `GET /api/approvals/{approvalId}` — the base approval object, always the very first call. Its response contains an `issueIds` array — treat that field as **context only**: seeing the ids there is not knowing the links, and acting on them (GETting or PATCHing any `/api/issues/...` route) before step 2 has run is a violation. +2. `GET /api/approvals/{approvalId}/issues` — always the second call, immediately after, **in the same bash call as step 1**, even though step 1's response (or the wake payload) already listed the linked issue ids. The two GETs are a **pair, not alternatives**: a wake that sends only one of them — either one — is failed, and fetching linked issues one-by-one by id never substitutes for the `/issues` route. No `/api/issues/...` call of any kind may appear before this pair has completed. +3. Read the decision `summary` from step 1's response and **classify before you write**: sort every linked issue id into exactly one of two lists — `RESOLVED` (the summary says the decision *fully resolves* it, e.g. "fully resolves X" / "X is resolved by this decision") and `OPEN` (everything else: linked "for context", "remains open", or simply not named as resolved). Write the two lists out explicitly (`RESOLVED=[…] OPEN=[…]`) before sending any write — a write sent before this classification is a guess. +4. Execute the lists mechanically — **both halves are mandatory writes**: one `PATCH` to `done` per `RESOLVED` id (leaving a `RESOLVED` issue open is exactly as much a failure as closing an `OPEN` one), and one `POST /comments` per `OPEN` id explaining why it stays open and what happens next — never a done PATCH on an `OPEN` id. "Approved" does **not** mean "close every linked issue", and caution does **not** mean "close nothing": the summary's own words decide each issue, one by one. Only an issue the summary is genuinely silent about defaults to `OPEN`. + +- `GET /api/approvals/{approvalId}` +- `GET /api/approvals/{approvalId}/issues` + +Call **both** routes, in that order, with no substitution in either direction: the base `GET /api/approvals/{approvalId}` always comes first (calling only the `/issues` route, even repeatedly, never satisfies it), and the `GET /api/approvals/{approvalId}/issues` call is equally mandatory right after it — fetching the linked issues one-by-one from ids in the wake payload does **not** replace the `/issues` route. The pair appears in every approval wake, even when the wake payload already states the decision, its reason, and the issue ids — a denied approval still gets the approval GET first, and the `/issues` route is the authoritative link set. Skipping either GET because the payload "already told you" is a violation: the approval object carries the decision `summary` you need for the close-scope decision below, and the payload's issue list may be stale or partial. They are read-only, so make them one shell call: + +```bash +curl -s "$PAPERCLIP_API_URL/api/approvals/$PAPERCLIP_APPROVAL_ID" -H "Authorization: Bearer $PAPERCLIP_API_KEY" +curl -s "$PAPERCLIP_API_URL/api/approvals/$PAPERCLIP_APPROVAL_ID/issues" -H "Authorization: Bearer $PAPERCLIP_API_KEY" +``` + +- For each linked issue: + - close it (`PATCH` status to `done`) **only** if the decision fully resolves that issue's requested work — read the approval's decision text/`summary`: when it says the decision resolves a subset of the linked issues, only that subset closes, 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. + + An approved decision does **not** mean "close every linked issue" — linked issues the decision merely relates to (or explicitly leaves open) get the comment branch, and when you are unsure whether an issue is fully resolved, comment instead of closing. + +**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. `inbox-lite` answers only **your** queue: a team-wide stock-take — who is on the team and what each teammate currently has in flight (a manager/team-lead-shaped ask) — is answered from two company-level reads instead, `GET /api/companies/{companyId}/agents` for the roster and a status-filtered `GET /api/companies/{companyId}/issues` joined in memory per assignee; your own inbox cannot see teammates' work, so a team summary sourced from it is fabrication. Worked example: *Manager Heartbeat* in `references/api-reference.md`. + +**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. **Budget gate:** when your identity/budget shows usage above 80%, the pick is restricted to `critical`-priority issues — checking out any non-critical issue while a `critical` one sits in your inbox is a violation, not a judgment call. + +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 first. If the issue is in an execution stage whose current participant is **not you** (the wake payload or issue names another participant/reviewer), do **not** checkout and do not send any status PATCH — reply via `POST /comments` only and end there (see the execution-policy rules). Otherwise, checkout and address the feedback (applies to `in_review` too). +- Wake reason `issue_children_completed` (or the wake payload shows all child issues done) → verify the children's final states with one GET, then close the parent: `PATCH` status `done` with a summary comment, unless the parent's own acceptance criteria still have open work. Do not re-plan or re-open finished children. +- `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 names a **resolved/expired interaction** (reason `interaction_resolved`, or the payload cites an interaction outcome) → read the **outcome before acting on it**. `accepted`/`answered` licenses the continuation you were waiting on. `stale_target`, `superseded_by_comment`, `cancelled`, or `expired` licenses **nothing**: the decision was never made, so do not close, promote, or implement off it — address the newer comment or revision that displaced it, and create a fresh interaction if the decision is still needed (recipes under **Issue-Thread Interactions**, *Target binding and staleness* / *Supersede on user comment*). +- Wake payload says `dependency-blocked interaction: yes` → the issue is still blocked for deliverable work and **checkout is not part of this heartbeat** — a checkout claims the issue for work, and there is no work to claim on a dependency-blocked issue. Do not try to unblock it and do not change its status. Read the comment, GET the issue to read `blockedBy`, and reply via `POST /comments` naming the unresolved blocker(s) as links with their current status. The reply is the deliverable. +- **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). This check outranks the checkout-first rule: on a `blocked` task where dedup might apply (your update may be the latest comment, or the ask is to check for new context), the **first** call is the comments GET — checkout comes only after you have confirmed there is genuinely new context to act on. If nothing is new, the heartbeat ends with **zero writes**: no checkout, no comment, and no status PATCH (the issue already holds its correct `blocked` status; re-sending it is a violation of this rule, not a closing write). +- Nothing assigned and no valid mention handoff → exit the heartbeat. + +**Step 5 — Checkout.** You MUST checkout before doing any work. The **only** way to check out is this POST — a status PATCH or a comment saying "checked out" does not claim the task. Copy this call (the double-quoted `-d` makes the env vars expand): + +```bash +curl -s -X POST "$PAPERCLIP_API_URL/api/issues/$ISSUE_ID/checkout" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + -d "{\"agentId\": \"$PAPERCLIP_AGENT_ID\", \"expectedStatuses\": [\"todo\", \"backlog\", \"blocked\", \"in_review\"]}" +``` + +If already checked out by you, returns normally. Assignment and status are not claims: an issue can be assigned to you and sitting in `in_progress` from a previous heartbeat and still not be checked out by **this run**. The checkout POST is the per-run claim — it is required every heartbeat before the first write, including (especially) on `in_progress` issues you were already working. It is idempotent, so there is never a reason to skip it. If owned by another agent: `409 Conflict` — **all work on that issue ends immediately**: no retry, no `heartbeat-context` fetch, no issue GETs, no workspace reads, no "investigating anyway". Your next action is a different assigned task, or a short closing note and exit. **Never retry a 409.** A 409 also cancels the closing-status-PATCH requirement for that issue: you never claimed it, so its status is not yours to set — after a 409 there are **zero further writes** to that issue (no `PATCH` with any status, including `in_progress` or `in_review`, and no comment); the closing note is plain assistant text, not an API call. + +The moment you pick an issue to work on, your **very next tool call is its checkout POST** — `heartbeat-context`, comment reads, and any workspace file access all come after the checkout has returned 2xx. + +**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`. On such an issue a reply **comment is your only write**: any `PATCH` that carries `status` counts as advancing the stage, **including re-sending the status it already has**, and the closing-status-PATCH rule does not apply because the disposition belongs to the current participant. Never write `executionState` through a PATCH body. If a write you were not required to make comes back `4xx validation_error`, stop — do not mutate the body and retry; drop the write entirely. + +**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. +- **Note-first ordering.** When the ask is to understand an issue and leave a note / plan of attack on it, the sequence is fixed: checkout → `GET …/heartbeat-context` (plus incremental comments only if genuinely needed) → **immediately** `POST /comments` with the plan composed from that context → closing disposition. The note is written from issue context, never from the codebase: do not list, read, or search repository files before that comment has landed — exploration, if needed at all, comes after the deliverable write. **Question-only carve-out:** when the wake is somebody asking you a question (a status ask, a "can you clarify…" comment), the answer comment is the *entire* deliverable — post it and stop. No closing status PATCH, no second summary comment: changing issue state because someone asked a question is overreach, and the fixed sequences above apply to work asks only. +- 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 **board/user** waits: approvals, `request_confirmation`, `ask_user_questions`, and `suggest_tasks`. But when what you are waiting for is **work another agent must perform** — a review, a design check, an implementation step — an interaction plus `in_review` is the wrong shape entirely: no interaction can assign work to an agent. Create an issue assigned to that agent, set your issue `blocked` with `blockedByIssueIds` pointing at it, and the `issue_blockers_resolved` wake resumes you the moment their work is done. +- 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. + +The upload is one multipart POST — never a JSON body, never a comment: + +```bash +curl -s -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues/$ISSUE_ID/attachments" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -F "file=@report.md" +``` + +**Trigger (mechanical):** any wrap-up ask whose deliverable is a finished file in your workspace — "the report/export/output is at `` in your workspace, wrap the task up" — selects the fixed sequence **checkout → attachments POST → work-products POST → closing done PATCH**. The attachments POST moves the bytes; the **work-products POST is what registers the deliverable for review** — an upload alone registers nothing. A comment naming or markdown-linking the filename is **not** delivery: the file's bytes reach the board only through the attachments POST, and the board's review path exists only after the work-products POST. Before any closing `done` PATCH, ask: did this work produce a deliverable? If yes, both writes must already have 2xx responses in this heartbeat. + +**Registering a work product is one POST** — `POST /api/issues/{issueId}/work-products` with the `X-Paperclip-Run-Id` header — never a comment and never a status field. Pick the body by deliverable shape: + +- **Uploaded file** → `{"type": "artifact", "isPrimary": true, "metadata": {"attachmentId": ""}}` (`isPrimary: true` when it is the main reviewable deliverable; the server canonicalizes the rest from the attachment). +- **Opened PR** → `{"type": "pull_request", "title": "", "url": ""}`. Same pattern for `preview_url` (published previews), `runtime_service` (managed preview/dev services), `commit` (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 — a PR link that lives only in a comment is unregistered. +- **File that intentionally stays in the project or execution workspace** (source file, committed report, generated index) → `{"type": "document", "metadata": {"resourceRef": {"kind": "workspace_file", "workspaceKind": "execution_workspace", "workspaceId": "", "relativePath": ""}}}`. The `workspaceId` is only obtainable from heartbeat-context — fetch it before composing the body. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path. + +**Trigger (mechanical, stays-in-workspace):** when the ask says the file should remain in the workspace — "keep it in the repo", "it stays in the workspace", "committed in the checkout", "no need to upload" — the sequence is **checkout → heartbeat-context GET (for the workspaceId) → work-products POST with `metadata.resourceRef.kind: "workspace_file"` → closing PATCH**, and the attachments POST is **skipped** (uploading would contradict the ask). This is a peer of the upload trigger above, not a variant of it. A comment or markdown link naming the path is not delivery here either — the resourceRef work product is the only thing that gives the board an open-from-the-issue path. + +For full payloads and the upload helper, 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: + +- Link-shaped output check (applies to every disposition, including a plain progress comment): if the update you are about to write mentions an opened PR, published preview, deployed service, notable commit, or handoff branch, the matching work product (`pull_request`, `preview_url`, `runtime_service`, `commit`, `branch` — Step 7 body shapes) must already be POSTed on this issue. Reporting the link in a comment or status text does not register it; the comments/status write comes **after** the work-products POST, never instead of it. +- `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue. `done` means *you performed the work* — a task that turned out to be unnecessary, superseded, or obsolete is **never yours to close** (not `done`, not `cancelled`): reassign it to your manager from `chainOfCommand` with a comment explaining why it appears unnecessary, and let the owner decide its disposition. One exception: when the board/user has **already decided** the issue is obsolete and explicitly asks you to close it out, the disposition decision is made — record it with `PATCH {"status": "cancelled", "comment": ""}`, never `done` and never DELETE (see Critical Rules). +- `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`, **assign it** — resolve the owning agent for the named team/role/person from `GET /api/companies/{companyId}/agents` and set `assigneeAgentId` in the create body (an unassigned follow-up is not a handoff; nobody will be woken to do it) — 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. + +Before sending **any** comment or description body, scan the text you are about to send for `{PREFIX}-{NUMBER}` tokens (e.g. `PAP-224`): every one must be written as an ASCII-hyphen markdown link — `[PAP-224](/PAP/issues/PAP-224)` — even in a one-line comment. A bare or typographic-dash ticket id in a body you send is always wrong (full rules in **Comment Style** below). + +Scan the same body for **ask-shaped text**: if the comment you are about to POST asks the user or board to provide, choose, confirm, approve, or answer anything ("please provide…", "let me know…", "which of these…"), stop — that write is wrong. A comment cannot capture a reply. Replace it with the matching typed interaction (`ask_user_questions` for values/answers, `request_confirmation` for a yes/no) chained with the `in_review` PATCH, per **Issue-Thread Interactions**; the comment you were composing becomes, at most, a pointer to the pending interaction. Posting the questions as a comment is a failed heartbeat even when the wording is perfect. + +Scan the same body for **raw agent mentions**: any `@Name` that refers to another agent must be rewritten as a structured mention — `[@Agent Name](agent://)`, with the id resolved from the company agents list — before the body is sent. Raw `@Name` text notifies nobody; only the `agent://` link form triggers the mentioned agent's heartbeat. + +Scan the same body for **deliverable links**: a URL or reference to a PR you opened, a preview you published, a deployed service, a notable commit, or a handoff branch. Each one requires the matching work-products POST (`pull_request`, `preview_url`, `runtime_service`, `commit`, `branch` — Step 7 body shapes) to have already returned 2xx on this issue in this heartbeat. "Record/report your progress" on a task where you opened a PR selects **two writes in order** — the work-products POST first, then the comment that mentions the link. If the work-products POST has not happened yet, stop and send it now, before the body you were composing; a progress comment carrying a deliverable link with no registered work product is a failed update even though the comment posts fine. + +```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`. The first call of any split-into-subtasks ask is `GET /api/issues/{parentIdOrIdentifier}` on the parent — **always, even when an inbox row, wake payload, or earlier summary already shows a `goalId`**: summaries are not authoritative, and every id in the create bodies (`parentId`, `goalId`) must come from the parent GET response you made this heartbeat. Always set **both** `parentId` and `goalId` in every subtask body — `goalId` is not inherited automatically; copy it from that parent GET. A child without `goalId` is orphaned from the goal rollup. 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 **and omit `parentId` entirely** — sharing the working copy does not make it a child, and the two fields are independent: `parentId` expresses task hierarchy only, never workspace continuity. If the request says the follow-up is *not* a subtask, sending `parentId` anyway is wrong even with the inherit field present. 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. + +`blockedByIssueIds` entries must be **internal issue `id` values, not display identifiers**. If you only have `PREFIX-N` identifiers, GET each issue first and use the `id` field from the response. + +**Creating an issue that starts blocked:** when a new issue carries unresolved blockers at creation time, the same POST body carries both fields — `blockedByIssueIds` **and** `status: "blocked"` (exactly as in the example above). An issue whose blockers are unresolved is not startable, so creating it as `todo` contradicts its own blocker list; never split this into a create followed by a status PATCH. + +**Marking an existing issue blocked on another issue:** the same single closing `PATCH` body carries all three fields — `status: "blocked"`, `blockedByIssueIds` with the blocker's **internal `id`**, and the `comment` naming the blocker as a markdown link. A blocked PATCH that names the blocker only in comment text has **not** recorded the dependency — nothing will wake the issue when the blocker resolves, and the close is failed even though the words are right. When you only know the blocker's `PREFIX-N` identifier, the `GET /api/issues/PREFIX-N` that resolves it to an internal `id` is a **required step of the blocked close**, not optional context — do it before composing the PATCH body. + +**Read blockers** from `GET /api/issues/{issueId}`: `blockedBy` (issues blocking this one) and `blocks` (issues this one blocks), each carrying id/identifier/title/status/priority **and an embedded `assignee` object with the owner's `name`**. When asked who owns, holds up, or is on the hook for an issue's blockers, this single GET is the entire method: answer with each blocker's `identifier` and its `assignee.name` exactly as returned, writing every identifier as an ASCII-hyphen markdown link (`[PREFIX-123](/PREFIX/issues/PREFIX-123)`) — a typographic or non-breaking hyphen inside an identifier corrupts it, in final replies as much as in comment bodies. Do not fetch each blocker issue one by one, and never call `GET /api/agents/{agentId}` — that route does not exist (the only agent lookups are `/api/agents/me` and `GET /api/companies/{companyId}/agents`), so a name-chasing curl/jq pipeline ends in `null`s. A checkout response that happens to echo blocker data does not replace this GET: an owner/blocker question is answered from an issue GET you actually made in this heartbeat. + +**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 + +Board approvals are for **spend, policy, and irreversible-action gates** (money, external posts, infrastructure). The **subject decides the mechanism, never the verb**: prompts say "sign-off", "approval", "confirmation", "go-ahead" interchangeably, and none of those words selects the endpoint. If the thing being decided involves **money in any amount or cadence** (a subscription, an add-on, a one-time purchase — any "$X" or "$X/month"), an external post, infrastructure, or an irreversible action, it is a **company approval**: `POST /api/companies/{companyId}/approvals` with `type: request_board_approval`, never an issue-thread interaction. Only when the thing being decided is **content on an issue** — does the board accept this plan/document/proposal revision — is the mechanism a `request_confirmation` **issue-thread interaction** on that issue (idempotencyKey `confirmation:{issueId}:plan:{revisionId}`, target bound to the latest revision — see **Issue-Thread Interactions** below). In particular, "write a plan and get board sign-off" (or "explicit board sign-off before implementation") selects `request_confirmation` bound to the plan revision you just PUT — a `POST /approvals` for a plan, document, or proposal is always the wrong mechanism regardless of how the sign-off is phrased, unless the plan's decision itself is spend, an external post, or an irreversible action. + +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. + +Because this body goes through a quoted heredoc, `requestedByAgentId` and every id in `issueIds` must be typed as the **concrete id characters** (your agent id from the checkout response or `/api/agents/me`, the issue's internal `id`) — a `$` variable typed inside the heredoc arrives as the literal text `$PAPERCLIP_…` and the approval is invalid even if the POST returns 2xx. Check the response echo: if any field comes back containing `$` or `{`, re-send with real values. + +**The approval POST never ends the heartbeat by itself.** A `POST /approvals` heartbeat is the same fixed shape as every other ask-the-board heartbeat (see the three-step recipe under Issue-Thread Interactions): checkout, then **one chained bash call** carrying the approval POST **`&&`** the waiting-posture `PATCH /api/issues/{id}` with `{"status": "in_review", "comment": "…what the board is deciding…"}`, then verify both 2xx echoes. Announcing "the board can now review" after only the POST is the standard failure this rule exists to prevent — an issue still `in_progress`/`todo` after its approval POST is a failed heartbeat, because nothing tells Paperclip the issue is waiting on the board. The PATCH belongs to the same heartbeat, not to the wake that comes after the decision. + +## 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. + +A decision is collected **only** by an interaction. Writing the options into a document or a comment puts nothing in front of the board — no card renders, no response can be typed, no wake ever comes. Any instruction of the form "have the board pick / select / choose / decide / answer" means your deliverable is a `POST /api/issues/{id}/interactions`, never a document PUT or comment. Asking questions in a comment is the same violation — a comment, however well-formatted, cannot capture a typed response: whenever you need values, answers, or choices back from the user, the write is an `ask_user_questions` (or other typed) interaction, not a comment that requests a reply. + +Interactions address the **human board/user only**. When the review, input, or sign-off you need is owned by another **agent** (a name from `GET /api/companies/{companyId}/agents` — a security engineer, a reviewer, a specialist), do not create an interaction: create an issue assigned to that agent and block your issue on it with `blockedByIssueIds` (see **Issue Dependencies**). The dependency wake — not a `continuationPolicy` — is what resumes your work automatically when their review lands. + +Five kinds are supported. Pick the smallest kind that fits the decision shape. Two subset-shaped kinds are easy to confuse; the test is **what acceptance does**: if accepted items should become **new issues** (proposals, follow-ups, anything phrased "become real work" / "become tasks"), the kind is **always** `suggest_tasks` — it *is* the subset picker for tasks, and accepted entries are minted as real subtasks. If the board is picking which of a known list of options **you should act on within the current work** (prioritizing what you do next, choosing configurations, selecting what to keep), that is `request_checkbox_confirmation`. For everything else the fastest discriminator is the response the board must give: one yes/no → `request_confirmation`; an **approve/reject/defer verdict on each item individually** (any "review/approve each of these" request) → `request_item_verdicts` with every item in `payload.items` — a checkbox list cannot carry per-item verdicts; a handful of typed answers → `ask_user_questions`. One binding is absolute: sign-off on a **plan, document, or proposal revision as a whole** is a single yes/no — `request_confirmation` bound to that revision (idempotencyKey `confirmation:{issueId}:plan:{revisionId}`) — **never** `request_checkbox_confirmation`, even when the plan's steps could be phrased as a selectable list. Checkbox is only for genuine subset-selection among independent alternatives; "approve this plan" has no subset. + +| 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). | Spend/infrastructure/external-action gates — money always goes through `POST /api/companies/{companyId}/approvals` (`request_board_approval`), not an interaction. Also: 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: + +- **Every "ask the board/user" heartbeat is one fixed three-step recipe** — no step is optional and the order never varies: + 1. **Checkout POST** for the issue (the per-run claim — required even when the issue is already yours or already `in_progress`). + 2. **One chained bash call** that carries both writes: the interaction POST (`--data @/tmp/body.json`) **`&&`** the closing `PATCH /api/issues/{id}` with `{"status": "in_review", "comment": "…what the board must answer…"}`. Shape the call so the body heredoc is a complete statement first — terminator alone on its own line — and the `curl … && curl …` chain is the next line; a `&&` on the heredoc terminator line, or a `curl` started before the heredoc closes, is the standard way this call breaks. If heredoc quoting keeps failing, compose the body with `jq -n` into the file instead. Composing the question body is not progress — the recipe is complete only when this chained call has run. + 3. **Verify both 2xx echoes** in the tool result. If either echo is missing, the missing write is your next action — send it now, before any reply text. Only then write the one-line closing summary. + + Announcing the questions in reply text, or ending after the interaction POST "because the wake will come", are the two standard failures this recipe exists to prevent. +- **Waiting posture (required).** The interactions POST is never the last write of a heartbeat. Only the closing `PATCH /api/issues/{id}` with `{"status": "in_review", "comment": …}` parks the issue in a waiting state — an interaction with no closing `in_review` PATCH leaves the issue dead, even though the card renders and the wake is configured. Because the stop-after-interaction mistake is so common, send the two as **one unit**: chain the closing PATCH onto the interaction POST in the same bash call (`curl … /interactions -d @… && curl … -X PATCH … -d '{"status":"in_review","comment":"…"}'`) so one delivery carries both. Posting the interaction and ending the heartbeat "because the wake will come" is precisely the dead state this rule exists to prevent. +- **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 in a fixed order: (1) `GET /api/issues/{issueId}/interactions` — the **list** endpoint; there is no per-interaction GET route, so fetching a single interaction id will 404 — and confirm the expired interaction's outcome and old target; (2) `GET` the target document and take its **latest** `revisionId`; (3) `POST` a fresh interaction bound to that revision (the idempotencyKey changes with it) chained with the `in_review` PATCH. Never resubmit or re-point the expired interaction, and never trust a revision id from memory or from the wake text — re-read the document. +- **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"`. The superseding comment is **feedback on the bound target document**, so "address the comment" means a document edit, not a reply: (1) GET the superseding comment and read what it asks to change; (2) apply those changes to the target document itself — `PUT /api/issues/{issueId}/documents/{key}`, producing a new `revisionId`; (3) if the decision is still needed, POST a fresh interaction bound to that new revision, chained with the `in_review` PATCH. The two standard failures are re-asking with the document unchanged and replying in a comment while the document stays stale — the board asked for a revision, so the document PUT is the deliverable, and no fresh approval request is honest until it points at the revised document. +- **Idempotency.** Use a deterministic `idempotencyKey` of the form `{kind-prefix}:{issueId}:{decisionKey}:{revisionId}`. The kind prefix is a **fixed literal, copied exactly**: `confirmation:` for `request_confirmation` (never abbreviations like `confirm:`), `checkbox:` for `request_checkbox_confirmation`, `verdicts:` for `request_item_verdicts`. Every other segment is a **literal value you already hold**: for an issue whose internal id is `i-0455` and whose plan PUT just returned `"revisionId": "rev-3021"`, the key is exactly `confirmation:i-0455:plan:rev-3021`. Braced tokens like `{issueId}` in this document are placeholders for you to replace — a key you send must never contain `{`, `}`, or `$`: a sent key with `$` or `{` in it means a placeholder or shell variable went through unexpanded, and the interaction will never match. Every segment must carry a real value: if a revision id you captured is empty or the string `null`, stop and re-fetch it — never send a key or `target.revisionId` containing `null`. +- **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}" + } + } +} +``` + +That JSON is only the request **body**. The sent form — for **every** interaction kind, not just this one — is a single bash call in which the interaction POST and the closing `in_review` PATCH are chained with `&&`, so one delivery carries both: + +```bash +curl -s -X POST "$PAPERCLIP_API_URL/api/issues//interactions" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" --data @body.json \ +&& curl -s -X PATCH "$PAPERCLIP_API_URL/api/issues/" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + -d '{"status": "in_review", "comment": "Waiting on board: "}' +``` + +Copying the payload shape but sending the POST alone is the most common interaction mistake: the card renders, yet the issue is left dead with no waiting posture. If you have just sent an interactions POST by itself, the chained PATCH is still owed — send it before anything else, including any closing summary. + +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). +- Invite, connect, or onboard an external OpenClaw agent or workstation — any ask mentioning an invite prompt, invite token, connect/gateway URL, or "OpenClaw" (CEO-initiated). The token and connection URL come **only** from the invite-prompt endpoint documented in `references/workflows.md` — read that playbook first; never compose an invite token or connection URL from memory. +- Set or clear an agent's `instructions-path`. +- CEO-safe company imports/exports. An **export ask is two writes, always**: `POST /api/companies/{companyId}/exports/preview` to inspect the inventory, then the producing `POST /api/companies/{companyId}/exports` narrowed with `selectedFiles` to exactly the parts asked for. "Inspect/preview what the package would include before producing it" instructs an order — preview **then** produce — never a stop after inspection: reporting the preview inventory (as a comment, table, or plan) without the final exports POST leaves the export undone. Keep task files out unless explicitly requested (`issues`/`projectIssues` stay off). Full route details in `references/workflows.md`. +- App-level self-test playbook. + +## Cases + +A **case** is a durable operational record — an incident or postmortem, a launch/announcement record, a vendor/customer/decision log — anything the ask phrases as "keep a record of this", "document this for the company", or "track this incident/launch" independent of any single issue's lifecycle. When the deliverable is such a record (rather than a change to ship), load `references/cases.md` **before writing anything**: cases are upserted by `key` at the company cases endpoint, they carry their own lifecycle `status`, and the durable content lives in the case **document body** — not in issue comments. + +Two disambiguators decide the write target: + +- **Case vs. child issue:** work you delegate to another agent is a child *issue*; a durable record — even one produced alongside ongoing work — is a *case*. Never put a case id in an issue's `parentId`; cases and issues are related only through the case links endpoint, not the issue tree. +- **Linking is a call, not a mention:** relate an issue to a case with `POST /api/cases/{caseIdOrIdentifier}/links` carrying the issue id and an explicit `role`. A markdown mention of the issue in a comment or document body is not a link. +- **Case values are closed enums — copy them from the reference, never improvise:** lifecycle `status` is one of `draft`, `in_progress`, `in_review`, `approved`, `done`, `cancelled`. A record whose work has started and not finished is `in_progress`; `active` is a **query filter** meaning any non-terminal status and is never a value you write into a case body. Link `role` is one of `origin` (the issue/run that created the case), `work` (an issue/run changing the case), `reference` (related issue context) — an ask to attach an issue as related/reference context takes `role: "reference"`; `related` is not a role value. + +## 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 in a **single PATCH that sets both fields together**: `assigneeUserId: ""` **and** `assigneeAgentId: null`, typically with status `in_review` instead of `done`. This is the one sanctioned use of `assigneeAgentId: null` — the "use `/release`, never null-PATCH the assignee" rule forbids returning work to the *pool* this way, but a hand-back to a named user requires the null so the task doesn't stay dual-assigned. Resolve the user id from the triggering comment's `authorUserId` when available, else the issue's `createdByUserId` if it matches the requester context. The one PATCH carries **all four fields together** — for a requester whose user id is `u-0007`, the body is exactly `{"assigneeUserId": "u-0007", "assigneeAgentId": null, "status": "in_review", "comment": "…"}`. A status-only PATCH (setting `in_review` without the two assignee fields) leaves the task on your plate and is a **failed hand-back even though the status looks right**: "send it back to me" is a reassignment request first, a status change second. +- **Start actionable work before planning-only closure.** Do concrete work in the same heartbeat unless the task asks for a plan or review only. +- **Mid-work notes are comment POSTs, and they still require checkout.** "Leave a comment", "post a note", "let the thread know", "say you're starting" → `POST /api/issues/{id}/comments` with `{"body": …}`, never a `comment` folded into a PATCH: `PATCH {"status": "in_progress"}` is invalid in every form, with or without a comment, and a PATCH `comment` may ride only a closing status (`done`/`in_review`/`blocked`) or a sanctioned reassignment. On your own issue a comment-only ask still starts with the checkout POST — send the checkout in its own bash call, confirm the 2xx echo, and only then send the note POST (never chain the note onto the checkout blindly: curl exits 0 on a `409 Conflict`, and a 409 cancels the note along with every other write to that issue). +- **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. And never launder a cancellation through another status: `done` means the requested work was actually performed and verified — closing an unnecessary/obsolete task as `done` (or any other terminal status) is still cancelling it. When a task looks unnecessary, the disposition decision belongs to its owner: reassign it to your manager (from `chainOfCommand`) with a comment explaining why it appears unnecessary — doubly so when it touches another team's deliverables. Trigger phrases make this mechanical: "no longer needed", "unnecessary", "obsolete", "superseded", "turned out to be redundant", "the other team wrote/did their own" all select the same single write — `PATCH /api/issues/{id}` with `{"assigneeAgentId": "", "comment": ""}` and **no terminal status** (never `done`, never `cancelled`; this reassignment is a sanctioned case of a `comment` riding an assignee change instead of a status change). The manager id comes from `chainOfCommand` in `GET /api/agents/me` — fetching your identity for this lookup is allowed even in a scoped-wake fast path. **One board-decided exception.** When the board/user has *already ruled* the work obsolete and the ask is to close it out — a decision plus a close-out directive ("board decision: … no longer needed — close the task out"), not merely your or a peer's observation that it looks unnecessary — there is no disposition left to escalate: close it yourself with `PATCH /api/issues/{id}` `{"status": "cancelled", "comment": ""}`. `cancelled` is the terminal state for intentionally abandoned work; `done` would claim work you never performed, and DELETE is never valid on issues. The trigger phrases above route to manager reassignment only when the obsolescence is an observation still awaiting an owner's decision. +- **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://)` instead of raw `@AgentName` text. +- **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only — above that line the only issues you may checkout are `critical`-priority ones; everything else stays untouched until budget recovers. +- **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 ` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip `. + +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. This applies to your final chat replies too: write ticket identifiers exactly as the API returns them — plain ASCII hyphen, no typographic dashes — and prefer the markdown-link form. **Copy, don't retype:** when a reply lists issues (a table, a bullet list, an answer naming blockers), take each identifier's characters from the `identifier` field of the API response and wrap them as `[PREFIX-123](/PREFIX/issues/PREFIX-123)`; retyping identifiers as prose is how Unicode dashes (`‑`, `–`) sneak in and corrupt them. + +**Agent mentions are structured (required):** any mention of another agent in a comment body MUST use the form `[@Agent Name](agent://)`, never raw `@AgentName` text. Resolve the agent id from the company agents list first. + +**Line breaks are content (required):** when the ask is for a multi-line comment, the JSON `body` you send must actually contain the breaks. In a compact single-line `curl -d`, write each break as a `\n` escape inside the JSON string — `-d '{"body": "line one\nline two"}'` delivers two real lines. Never merge the requested lines into one run-on sentence, and never rely on a raw newline typed inside a single-line command (it does not survive; the `\n` escape or the heredoc helper above are the only safe forms). + +**Human-facing text uses display identifiers and fetched names (required):** in comment bodies and final replies, refer to issues by their display `identifier` (`PREFIX-123`, as a markdown link) — never by internal `id` values — and name agents/users with the `name` field from a GET response you actually received. If you find yourself writing the literal text `null` (or an unresolved placeholder like `NAME_HERE`) where a name or identifier belongs, the value was never resolved: stop, GET the entity (e.g. the agent behind `assigneeAgentId`), and write the real name. A body containing `null` or an unfilled placeholder must never be sent. + +**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: `//issues/` (e.g., `/PAP/issues/PAP-224`) +- Issue comments: `//issues/#comment-` (deep link to a specific comment) +- Issue documents: `//issues/#document-` (deep link to a specific document such as `plan`) +- Agents: `//agents/` (e.g., `/PAP/agents/claudecoder`) +- Projects: `//projects/` (id fallback allowed) +- Approvals: `//approvals/` — `` is the **company** prefix taken from your issue identifiers, and `` is copied **verbatim, case and all**, from the API response. An approval id is not a ticket identifier: never uppercase it, never derive a URL prefix from it, and when naming it in text write the exact id string as returned. +- Runs: `//agents//runs/` + +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: `//issues/#document-plan` +- Generic document: `//issues/#document-` + +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. This is mechanical, not stylistic: every comment that announces a plan or document change MUST carry the deep link as a literal markdown path — e.g. `[plan](//issues/#document-plan)` with real values — in its body. Writing "see the updated plan document" (or any equivalent phrase) **without** that link is a violation: the reader has nothing to click. + +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. + +"Update the plan document" applies only when you have plan content to write. When the ask is **sign-off on a plan that already exists** ("get the board's sign-off on the existing plan"), there is nothing to PUT — re-PUTting the same body just mints a new revision and invalidates the sign-off target. The recipe collapses to three calls: `GET /api/issues/{id}/documents/plan` to read the **latest `revisionId`** from the existing document, `POST .../interactions` with `kind: request_confirmation` bound to that revision (idempotencyKey `confirmation:{issueId}:plan:{revisionId}`, real values per Issue-Thread Interactions), then the `PATCH` to `in_review`. Reading the plan and stopping, or rewriting the plan instead of requesting the confirmation, are both failed sign-off heartbeats — the deliverable is the pending interaction, not the document. + +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 with the **key-specific route** `GET /api/issues/{issueId}/documents/plan` (not just the documents list) and send its latest revision id as `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`). + +Build the query **from the question** before sending, one clause per named concept, all in **one** request: every topic/keyword ("about deployment", "regarding onboarding") becomes `q=`, every named status ("open (todo)") becomes `status=`, every named person becomes the resolved `assigneeAgentId`. Status/assignee filters alone cannot match content — a topic word in the question **requires** `q=` in the query, e.g. `?q=onboarding&status=todo&assigneeAgentId={id}`. Fetching a broader list and filtering it yourself is a **failed search even when your final answer is right**: the server-side search is the required mechanism, not an optimization. + +**Worked example** — "Which open (todo) items about payments are on GadgetCoder's plate?" names three concepts — topic **payments**, status **todo**, person **GadgetCoder** — so the method is exactly two reads: + +1. `GET /api/companies/{companyId}/agents` → copy the `id` of the agent named GadgetCoder. +2. `GET /api/companies/{companyId}/issues?q=payments&status=todo&assigneeAgentId=` — one request, one query parameter per named concept. + +Answer with the returned `identifier` values as markdown links. **Pre-send check (mandatory):** count the named concepts in the question, then count your query parameters — they must match one-for-one, and every topic word must appear as `q=`. A query carrying `status=` and `assigneeAgentId=` but **no `q=`** has silently dropped the topic and is a failed search even when the right issue happens to be in the response. + +When the question concerns **another named agent or user** (their workload, their assignments), first resolve that name to an id via `GET /api/companies/{companyId}/agents` and filter with **that** id — never substitute your own id (`$PAPERCLIP_AGENT_ID`) for a named third party. Match the status filter to the statuses the question actually names: if it asks about items in one specific status, filter on exactly that status, not the default my-assignments status set. + +To answer a question about one **specific known issue** (its blockers, owners, relations, status), do not rely on the company list endpoint — list results are compact summaries that omit relationship detail. `GET /api/issues/{idOrIdentifier}` directly and read the full object. + +## 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. diff --git a/skills-releases/paperclip/v7-roster/references/api-reference.md b/skills-releases/paperclip/v7-roster/references/api-reference.md new file mode 100644 index 0000000000..1b26b59aef --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/api-reference.md @@ -0,0 +1,1287 @@ +# Paperclip API Reference + +Detailed reference for the Paperclip control plane API. For the core heartbeat procedure and critical rules, see the main `SKILL.md`. + +--- + +## Response Schemas + +### Agent Record (`GET /api/agents/me` or `GET /api/agents/:agentId`) + +```json +{ + "id": "agent-42", + "name": "BackendEngineer", + "role": "engineer", + "title": "Senior Backend Engineer", + "companyId": "company-1", + "reportsTo": "mgr-1", + "capabilities": "Node.js, PostgreSQL, API design", + "status": "running", + "budgetMonthlyCents": 5000, + "spentMonthlyCents": 1200, + "chainOfCommand": [ + { + "id": "mgr-1", + "name": "EngineeringLead", + "role": "manager", + "title": "VP Engineering" + }, + { + "id": "ceo-1", + "name": "CEO", + "role": "ceo", + "title": "Chief Executive Officer" + } + ] +} +``` + +Use `chainOfCommand` to know who to escalate to. Use `budgetMonthlyCents` and `spentMonthlyCents` to check remaining budget. + +### Company Portability + +CEO-safe package routes are company-scoped: + +- `POST /api/companies/:companyId/imports/preview` +- `POST /api/companies/:companyId/imports/apply` +- `POST /api/companies/:companyId/exports/preview` +- `POST /api/companies/:companyId/exports` + +Rules: + +- Allowed callers: board users and the CEO agent of that same company +- Safe import routes reject `collisionStrategy: "replace"` +- Existing-company safe imports only create new entities or skip collisions +- `new_company` safe imports are allowed and copy active user memberships from the source company +- Export preview defaults to `issues: false`; add task selectors explicitly when needed +- Use `selectedFiles` on export to narrow the final package after previewing the inventory + +Example safe import preview: + +```json +POST /api/companies/company-1/imports/preview +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "target": { "mode": "existing_company", "companyId": "company-1" }, + "collisionStrategy": "rename" +} +``` + +Example new-company safe import: + +```json +POST /api/companies/company-1/imports/apply +{ + "source": { "type": "github", "url": "https://github.com/acme/agent-company" }, + "include": { "company": true, "agents": true, "projects": true, "issues": false }, + "target": { "mode": "new_company", "newCompanyName": "Imported Acme" }, + "collisionStrategy": "rename" +} +``` + +Example export preview without tasks: + +```json +POST /api/companies/company-1/exports/preview +{ + "include": { "company": true, "agents": true, "projects": true } +} +``` + +Example narrowed export with explicit tasks: + +```json +POST /api/companies/company-1/exports +{ + "include": { "company": true, "agents": true, "projects": true, "issues": true }, + "selectedFiles": [ + "COMPANY.md", + "agents/ceo/AGENTS.md", + "skills/paperclip/SKILL.md", + "tasks/pap-42/TASK.md" + ] +} +``` + +### Issue with Ancestors (`GET /api/issues/:issueId`) + +Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy. + +The response also includes `blockedBy` and `blocks` arrays showing first-class dependency relationships: + +```json +{ + "id": "issue-99", + "title": "Implement login API", + "parentId": "issue-50", + "projectId": "proj-1", + "goalId": null, + "blockedBy": [ + { "id": "issue-80", "identifier": "PAP-80", "title": "Design auth schema", "status": "in_progress", "priority": "high", "assigneeAgentId": "agent-55", "assigneeUserId": null } + ], + "blocks": [], + "project": { + "id": "proj-1", + "name": "Auth System", + "description": "End-to-end authentication and authorization", + "status": "active", + "goalId": "goal-1", + "primaryWorkspace": { + "id": "ws-1", + "name": "auth-repo", + "cwd": "/Users/me/work/auth", + "repoUrl": "https://github.com/acme/auth", + "repoRef": "main", + "isPrimary": true + }, + "workspaces": [ + { + "id": "ws-1", + "name": "auth-repo", + "cwd": "/Users/me/work/auth", + "repoUrl": "https://github.com/acme/auth", + "repoRef": "main", + "isPrimary": true + } + ] + }, + "goal": null, + "ancestors": [ + { + "id": "issue-50", + "title": "Build auth system", + "status": "in_progress", + "priority": "high", + "assigneeAgentId": "mgr-1", + "projectId": "proj-1", + "goalId": "goal-1", + "description": "...", + "project": { + "id": "proj-1", + "name": "Auth System", + "description": "End-to-end authentication and authorization", + "status": "active", + "goalId": "goal-1" + }, + "goal": { + "id": "goal-1", + "title": "Launch MVP", + "description": "Ship minimum viable product by Q1", + "level": "company", + "status": "active" + } + }, + { + "id": "issue-10", + "title": "Launch MVP", + "status": "in_progress", + "priority": "critical", + "assigneeAgentId": "ceo-1", + "projectId": "proj-1", + "goalId": "goal-1", + "description": "...", + "project": { "..." : "..." }, + "goal": { "..." : "..." } + } + ] +} +``` + +Blocker wake semantics are strict: `issue_blockers_resolved` only fires when every blocker reaches `done`. A blocker moved to `cancelled` still requires manual re-triage or relation cleanup. + +### Blocker Diagnostics (`GET /api/issues/:issueId/diagnostics/blockers`) + +Use this read-only diagnostic when an issue appears stuck on dependencies, especially after an `issue_blockers_resolved` wake or when an issue looks blocked against a blocker that is already `done`. + +Read `diagnosis` first. It is a deterministic, nullable explanation derived only from fields included in the response. The endpoint also returns bounded structured blocker rows with status, readiness, and anomaly flags: + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "All blockers for PAP-99 are resolved, but the issue is still blocked; this is likely a stale blocker hold.", + "readiness": { "allBlockersDone": true, "isDependencyReady": true, "unresolvedBlockerCount": 0, "pendingFinalizeBlockerCount": 0 }, + "blockers": [ + { + "id": "issue-80", + "identifier": "PAP-80", + "title": "Design auth schema", + "status": "done", + "priority": "high", + "assigneeAgentId": "agent-55", + "assigneeUserId": null, + "isUnresolved": false, + "isDependencyReady": true, + "isPendingFinalize": false, + "flags": ["done_but_blocking"] + } + ], + "omittedUnauthorizedBlockerCount": 0, + "truncated": false, + "caps": { "maxBlockers": 100 } +} +``` + +Security and bounds: + +- The root issue and every returned blocker are independently checked against `issue:read`; unauthorized blockers are omitted. +- `omittedUnauthorizedBlockerCount` is a number only when the result is not truncated; it is `null` when `truncated` is `true` because blockers beyond the cap may also be unauthorized. +- If blockers are omitted or the result is truncated, `readiness` is `null` and `diagnosis` does not mention hidden blocker ids, statuses, assignees, or reasons. +- No raw wake payloads, activity details, errors, or trigger blobs are returned by this Slice-1 endpoint. + +### Wake Diagnostics (`GET /api/issues/:issueId/diagnostics/wakes`) + +Use this read-only diagnostic when you need to answer why an issue's assignee was or was not woken. Read `diagnosis` first; `likelyReason` is the same value for callers that prefer that name. The string is deterministic, nullable, and derived only from fields included in the response plus authorized blocker state. + +The endpoint returns bounded wake/activity events, newest-first across both event kinds: + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.", + "likelyReason": "No wake row exists for PAP-99 in the bounded window. PAP-99 is blocked by PAP-80, which is in_progress, so issue_blockers_resolved has not fired.", + "events": [ + { + "kind": "wake_request", + "agentId": "agent-1", + "source": "automation", + "reason": "issue_blockers_resolved", + "status": "completed", + "coalescedCount": 0, + "runId": "run-1", + "requestedAt": "2026-07-07T00:00:00.000Z", + "claimedAt": "2026-07-07T00:00:01.000Z", + "finishedAt": "2026-07-07T00:00:10.000Z", + "failureClass": null + } + ], + "wakeRequestCount": 1, + "activityRecordCount": 0, + "truncated": false, + "truncatedSections": { "wakeRequests": false, "activityRecords": false }, + "caps": { "maxWakeRequests": 50, "maxActivityRecords": 50, "lookbackDays": 14 } +} +``` + +Security and bounds: + +- The root issue must pass normal issue-read authorization, and Case-B blocker inference uses the same per-blocker authorization rules as blocker diagnostics. +- Wake rows are matched only through allowlisted issue/task id fields in the wake payload. Raw `payload`, raw activity `details`, raw `error`, and raw `triggerDetail` are never returned. +- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`. +- Wake `source`, `reason`, and `status` are projected through coarse allowlists; unknown producer text is returned as `other`. +- Failure detail is exposed only as `failureClass` (`failed`, `cancelled`, or `skipped`), never raw error text. +- Activity records are limited to wake defer/suppression actions and exact allowlisted fields such as `rootIssueId`, `holdId`, `source`, `requestedReason`, and `previousReason`. +- Results are capped to 50 wake requests and 50 activity records within a 14-day lookback. If either cap is hit, `truncated` is `true` and the diagnosis states that it only covers returned records. + +### Subtree Diagnostics (`GET /api/issues/:issueId/diagnostics/subtree`) + +Use this read-only diagnostic when an issue has child work and you need the combined wake/dependency view for the subtree. Read top-level `diagnosis` first; `likelyReason` is the same value. The response omits unauthorized subtree nodes and hidden blocker nodes before deriving diagnosis text. + +```json +{ + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "diagnosis": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.", + "likelyReason": "PAP-99 appears to be the subtree stall point: PAP-99 is blocked by PAP-80, which is in_progress.", + "nodes": [ + { + "issue": { "id": "issue-99", "identifier": "PAP-99", "title": "Ship API", "status": "blocked", "priority": "medium", "assigneeAgentId": "agent-1", "assigneeUserId": null }, + "parentId": null, + "depth": 0, + "diagnosis": "PAP-99 is blocked by PAP-80, which is in_progress.", + "likelyReason": "PAP-99 is blocked by PAP-80, which is in_progress.", + "blockers": [ + { "id": "issue-80", "identifier": "PAP-80", "title": "Finish dependency", "status": "in_progress", "priority": "medium", "assigneeAgentId": "agent-2", "assigneeUserId": null, "isUnresolved": true, "isDependencyReady": false, "isPendingFinalize": false, "flags": [] } + ], + "blockerReadiness": { "allBlockersDone": false, "isDependencyReady": false, "unresolvedBlockerCount": 1, "pendingFinalizeBlockerCount": 0 }, + "omittedUnauthorizedBlockerCount": 0, + "wakeEvents": [], + "wakeRequestCount": 0, + "activityRecordCount": 0, + "truncated": false, + "truncatedSections": { "blockers": false, "wakeRequests": false, "activityRecords": false } + } + ], + "edges": [ + { "kind": "blocks", "fromIssueId": "issue-80", "toIssueId": "issue-99", "timestamp": "2026-07-07T00:00:00.000Z" }, + { "kind": "wake_request", "issueId": "issue-99", "agentId": "agent-1", "reason": "issue_blockers_resolved", "status": "completed", "timestamp": "2026-07-07T00:01:00.000Z" } + ], + "nodeCount": 1, + "omittedUnauthorizedNodeCount": 0, + "truncated": false, + "truncatedSections": { "nodes": false, "depth": false, "blockers": false, "wakeRequests": false, "activityRecords": false }, + "caps": { "maxDepth": 8, "maxNodes": 100, "maxBlockersPerNode": 20, "maxWakeRequestsPerNode": 5, "maxActivityRecordsPerNode": 5, "lookbackDays": 14 } +} +``` + +Security and bounds: + +- The root issue must pass normal issue-read authorization. Every returned subtree node and blocker node is independently checked against `issue:read`; unauthorized nodes and blocker rows are omitted. +- `diagnosis` and per-node `likelyReason` are deterministic and derived only from returned authorized node, blocker, wake, and activity projections. +- Raw wake `payload`, activity `details`, raw `error`, and `triggerDetail` are never returned. Wake fields use the same coarse projections as wake diagnostics. +- Low-trust or boundary-scoped callers that cannot read company scope receive `null` for internal wake `agentId`/`runId` and activity `agentId`/`runId`/`holdId`. +- The subtree walk is capped to depth 8 and 100 nodes with a cycle guard. Per-node blockers, wake requests, and activity records are also capped. Any cap hit sets `truncated: true` and the relevant `truncatedSections` flag. + +### Execution Policy Fields On An Issue + +When an issue has review or approval gates, `GET /api/issues/:issueId` can also include `executionPolicy` and `executionState`: + +```json +{ + "status": "in_review", + "executionPolicy": { + "mode": "normal", + "commentRequired": true, + "stages": [ + { + "id": "stage-review", + "type": "review", + "approvalsNeeded": 1, + "participants": [ + { "id": "participant-qa", "type": "agent", "agentId": "qa-agent-id" } + ] + }, + { + "id": "stage-approval", + "type": "approval", + "approvalsNeeded": 1, + "participants": [ + { "id": "participant-cto", "type": "user", "userId": "cto-user-id" } + ] + } + ] + }, + "executionState": { + "status": "pending", + "currentStageId": "stage-review", + "currentStageIndex": 0, + "currentStageType": "review", + "currentParticipant": { "type": "agent", "agentId": "qa-agent-id" }, + "returnAssignee": { "type": "agent", "agentId": "coder-agent-id" }, + "completedStageIds": [], + "lastDecisionId": null, + "lastDecisionOutcome": null + } +} +``` + +Interpretation: + +- `currentStageType` tells you whether the active gate is `review` or `approval` +- `currentParticipant` is the only actor allowed to advance the stage +- `returnAssignee` is who gets the task back when changes are requested +- `lastDecisionOutcome` shows the latest gate decision + +There is **no separate execution-decision endpoint**. Review and approval decisions are submitted through `PATCH /api/issues/:issueId`, and Paperclip records the decision row automatically. + +### Cross-Agent Review Gates + +Use native execution stages for cross-agent code or deliverable review gates. The gate belongs on the source issue's `executionPolicy.stages[]`, with the reviewer or approver listed in `participants[]` and the stage `type` set to `review` or `approval`. + +Minimal agent-review gate: + +```json +PATCH /api/issues/:issueId +{ + "executionPolicy": { + "stages": [ + { + "type": "review", + "participants": [ + { "type": "agent", "agentId": "" } + ] + } + ] + } +} +``` + +When the executor finishes work, move the source issue to `in_review`. Paperclip advances the issue to the active stage participant through `executionState.currentParticipant`, and that participant decides through the normal issue update route: + +- approve/sign off with `PATCH /api/issues/:issueId` using `{ "status": "done", "comment": "Approved: ..." }` +- request changes with `PATCH /api/issues/:issueId` using `{ "status": "in_progress", "comment": "Changes requested: ..." }` + +Agent heartbeat implementations should follow the Paperclip skill's **Execution-policy review/approval wakes** procedure when they are assigned as the active gate participant. + +Do not model cross-agent review gates as bridge child issues, freeform comments, ad-hoc `request_confirmation` cards, responder fields, mention grants, or broadened comment/interaction authorization. Those workarounds either split the audit trail away from the source issue or loosen authorization around who may decide. The native execution-stage path keeps the gate, reviewer authority, return assignee, decision row, wake behavior, and audit history on the issue that is actually being reviewed. + +--- + +## Worked Example: IC Heartbeat + +A concrete example of what a single heartbeat looks like for an individual contributor. + +``` +# 1. Identity (skip if already in context) +GET /api/agents/me +-> { id: "agent-42", companyId: "company-1", ... } + +# 2. Check inbox +GET /api/companies/company-1/issues?assigneeAgentId=agent-42&status=todo,in_progress,in_review,blocked +-> [ + { id: "issue-101", title: "Fix rate limiter bug", status: "in_progress", priority: "high" }, + { id: "issue-99", title: "Implement login API", status: "todo", priority: "medium" } + ] + +# 3. Already have issue-101 in_progress (highest priority). Continue it. +GET /api/issues/issue-101 +-> { ..., ancestors: [...] } + +GET /api/issues/issue-101/comments +-> [ { body: "Rate limiter is dropping valid requests under load.", authorAgentId: "mgr-1" } ] + +# 4. Do the actual work (write code, run tests) + +# 5. Work is done. Update status and comment in one call. +PATCH /api/issues/issue-101 +{ "status": "done", "comment": "Fixed sliding window calc. Was using wall-clock instead of monotonic time." } + +# 6. Still have time. Checkout the next task. +POST /api/issues/issue-99/checkout +{ "agentId": "agent-42", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] } + +GET /api/issues/issue-99 +-> { ..., ancestors: [{ title: "Build auth system", ... }] } + +# 7. Made partial progress, not done yet. Comment and exit. +PATCH /api/issues/issue-99 +{ "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } +``` + +### Worked Example: Report A Board User's Mine Inbox + +When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses. + +``` +# Board user created the requesting issue. +GET /api/issues/issue-200 +-> { id: "issue-200", createdByUserId: "user-7", ... } + +# Fetch the board user's Mine inbox issues. +GET /api/agents/me/inbox/mine?userId=user-7 +-> [ + { + id: "issue-310", + identifier: "PAP-310", + title: "Review CEO strategy revision", + status: "in_review", + myLastTouchAt: "2026-03-26T18:00:00.000Z", + lastExternalCommentAt: "2026-03-26T19:10:00.000Z", + isUnreadForMe: true + } + ] + +# Summarize it back to the board in a comment or document. +PATCH /api/issues/issue-200 +{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } +``` + +### Worked Example: Reviewer / Approver Heartbeat + +When you wake up on an issue in `in_review`, inspect `executionState` first: + +``` +GET /api/issues/issue-77 +-> { + id: "issue-77", + status: "in_review", + assigneeAgentId: "qa-agent-id", + executionState: { + status: "pending", + currentStageType: "review", + currentParticipant: { type: "agent", agentId: "qa-agent-id" }, + returnAssignee: { type: "agent", agentId: "coder-agent-id" } + } + } +``` + +If `currentParticipant` is you, approve the current stage by patching the issue to `done` with a required comment: + +``` +PATCH /api/issues/issue-77 +{ "status": "done", "comment": "QA signoff complete. Verified the regression and test coverage." } +``` + +Paperclip writes the execution decision automatically. If another stage remains, the issue stays in `in_review` and is reassigned to the next participant. If this was the final stage, the issue reaches actual `done`. + +To request changes, use a non-`done` status with a required comment. Prefer `in_progress`: + +``` +PATCH /api/issues/issue-77 +{ "status": "in_progress", "comment": "Changes requested: add a regression test for the empty-state path." } +``` + +Paperclip converts that into a `changes_requested` decision, reassigns the issue to `returnAssignee`, and routes it back to the same stage when the executor resubmits. + +--- + +## Worked Example: Manager Heartbeat + +``` +# 1. Identity (skip if already in context) +GET /api/agents/me +-> { id: "mgr-1", role: "manager", companyId: "company-1", ... } + +# 2. Check team status +GET /api/companies/company-1/agents +-> [ { id: "agent-42", name: "BackendEngineer", reportsTo: "mgr-1", status: "idle" }, ... ] + +GET /api/companies/company-1/issues?assigneeAgentId=agent-42&status=in_progress,blocked +-> [ { id: "issue-55", status: "blocked", title: "Needs DB migration reviewed" } ] + +# 3. Agent-42 is blocked. Read comments. +GET /api/issues/issue-55/comments +-> [ { body: "Blocked on DBA review. Need someone with prod access.", authorAgentId: "agent-42" } ] + +# 4. Unblock: reassign and comment. +PATCH /api/issues/issue-55 +{ "assigneeAgentId": "dba-agent-1", "comment": "@DBAAgent Please review the migration in PR #38." } + +# 5. Check own assignments. +GET /api/companies/company-1/issues?assigneeAgentId=mgr-1&status=todo,in_progress +-> [ { id: "issue-30", title: "Break down Q2 roadmap into tasks", status: "todo" } ] + +POST /api/issues/issue-30/checkout +{ "agentId": "mgr-1", "expectedStatuses": ["todo", "backlog", "blocked", "in_review"] } + +# 6. Create subtasks and delegate. +POST /api/companies/company-1/issues +{ "title": "Implement caching layer", "assigneeAgentId": "agent-42", "parentId": "issue-30", "status": "todo", "priority": "high", "goalId": "goal-1" } + +POST /api/companies/company-1/issues +{ "title": "Write load test suite", "assigneeAgentId": "agent-55", "parentId": "issue-30", "status": "blocked", "priority": "medium", "goalId": "goal-1", "blockedByIssueIds": [""] } +# ^ Load tests depend on caching layer being done first. Paperclip will auto-wake agent-55 when the blocker resolves. + +PATCH /api/issues/issue-30 +{ "status": "done", "comment": "Broke down into subtasks for caching layer and load testing." } + +# 7. Dashboard for health check. +GET /api/companies/company-1/dashboard +``` + +--- + +## Comments and @-mentions + +Comments are your primary communication channel. Use them for status updates, questions, findings, handoffs, and review requests. + +Use markdown formatting and include links to related entities when they exist: + +```md +## Update + +- Approval: [APPROVAL_ID](//approvals/) +- Pending agent: [AGENT_NAME](//agents/) +- Source issue: [ISSUE_ID](//issues/) +``` + +Where `` is the company prefix derived from the issue identifier (e.g., `PAP-123` → prefix is `PAP`). + +**@-mentions:** Agent mentions in comments can automatically wake the target agent. + +For machine-authored comments, do not rely on raw `@AgentName` text. Raw text is unreliable for names containing spaces. Instead: + +1. Resolve the target agent with `GET /api/companies/{companyId}/agents` +2. Find the agent's exact display name and `id` +3. Emit a structured markdown mention using the agent ID: + +``` +POST /api/issues/{issueId}/comments +{ "body": "[@QA Reviewer](agent://qa-agent-id) please review this implementation." } +``` + +The reliable machine-authored format is `[@Display Name](agent://)`. This triggers a heartbeat for the mentioned agent. Structured agent mentions also work inside the `comment` field of `PATCH /api/issues/{issueId}`. + +Raw `@AgentName` text may still work for some single-token names, but treat it as a fallback only, not the default. + +**Do NOT:** + +- Use @-mentions as your default assignment mechanism. If you need someone to do work, create/assign a task. +- Mention agents unnecessarily. Each mention triggers a heartbeat that costs budget. + +**Exception (handoff-by-mention):** + +- If an agent is explicitly @-mentioned with a clear directive to take the task, that agent may read the thread and self-assign via checkout for that issue. +- This is a narrow fallback for missed assignment flow, not a replacement for normal assignment discipline. + +--- + +## Cross-Team Work and Delegation + +You have **full visibility** across the entire org. The org structure defines reporting and delegation lines, not access control. + +### Receiving cross-team work + +When you receive a task from outside your reporting line: + +1. **You can do it** — complete it directly. +2. **You can't do it** — mark it `blocked` and comment why. +3. **You question whether it should be done** — you **cannot cancel it yourself**. Reassign to your manager with a comment. Your manager decides. + +**Do NOT** cancel a task assigned to you by someone outside your team. + +### Escalation + +If you're stuck or blocked: + +- Comment on the task explaining the blocker. +- If you have a manager (check `chainOfCommand`), reassign to them or create a task for them. +- Never silently sit on blocked work. + +--- + +## Company Context + +``` +GET /api/companies/{companyId} — company name, description, budget +GET /api/companies/{companyId}/goals — goal hierarchy (company > team > agent > task) +GET /api/companies/{companyId}/projects — projects (group issues toward a deliverable) +GET /api/projects/{projectId} — single project details +GET /api/companies/{companyId}/dashboard — health summary: agent/task counts, spend, stale tasks +``` + +Use the dashboard for situational awareness, especially if you're a manager or CEO. + +## Company Branding (CEO / Board) + +CEO agents can update branding fields on their own company. Board users can update all fields. + +``` +GET /api/companies/{companyId} — read company (CEO agents + board) +PATCH /api/companies/{companyId} — update company fields +POST /api/companies/{companyId}/logo — upload logo (multipart, field: "file") +``` + +**CEO-allowed fields:** `name`, `description`, `brandColor` (hex e.g. `#FF5733` or null), `logoAssetId` (UUID or null). + +**Board-only fields:** `status`, `budgetMonthlyCents`, `spentMonthlyCents`, `requireBoardApprovalForNewAgents`. + +**Not updateable:** `issuePrefix` (used as company slug/identifier — protected from changes). + +**Logo workflow:** +1. `POST /api/companies/{companyId}/logo` with file upload → returns `{ assetId }`. +2. `PATCH /api/companies/{companyId}` with `{ "logoAssetId": "" }`. + +## OpenClaw Invite Prompt (CEO) + +Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt: + +``` +POST /api/companies/{companyId}/openclaw/invite-prompt +{ + "agentMessage": "optional note for the joining OpenClaw agent" +} +``` + +Response includes invite token, onboarding text URL, and expiry metadata. + +Access is intentionally constrained: +- board users with invite permission +- CEO agent only (non-CEO agents are rejected) + +--- + +## Setting Agent Instructions Path + +Use the dedicated endpoint when setting an adapter instructions markdown path (`AGENTS.md`-style files): + +``` +PATCH /api/agents/{agentId}/instructions-path +{ + "path": "agents/cmo/AGENTS.md" +} +``` + +Authorization: +- target agent itself, or +- an ancestor manager in the target agent's reporting chain. + +Adapter behavior: +- `codex_local` and `claude_local` default to `adapterConfig.instructionsFilePath` +- relative paths resolve against `adapterConfig.cwd` +- absolute paths are stored as-is +- clear by sending `{ "path": null }` + +For adapters with a non-default key: + +``` +PATCH /api/agents/{agentId}/instructions-path +{ + "path": "/absolute/path/to/AGENTS.md", + "adapterConfigKey": "adapterSpecificPathField" +} +``` + +--- + +## Project Setup (Create + Workspace) + +When a CEO/manager task asks you to "set up a new project" and wire local + GitHub context, use this sequence. + +### Option A: One-call create with workspace + +``` +POST /api/companies/{companyId}/projects +{ + "name": "Paperclip Mobile App", + "description": "Ship iOS + Android client", + "status": "planned", + "goalIds": ["{goalId}"], + "workspace": { + "name": "paperclip-mobile", + "cwd": "/Users/me/paperclip-mobile", + "repoUrl": "https://github.com/acme/paperclip-mobile", + "repoRef": "main", + "isPrimary": true + } +} +``` + +### Option B: Two calls (project first, then workspace) + +``` +POST /api/companies/{companyId}/projects +{ + "name": "Paperclip Mobile App", + "description": "Ship iOS + Android client", + "status": "planned" +} + +POST /api/projects/{projectId}/workspaces +{ + "cwd": "/Users/me/paperclip-mobile", + "repoUrl": "https://github.com/acme/paperclip-mobile", + "repoRef": "main", + "isPrimary": true +} +``` + +Workspace rules: + +- Provide at least one of `cwd` or `repoUrl`. +- For repo-only setup, omit `cwd` and provide `repoUrl`. +- The first workspace is primary by default. + +Project responses include `primaryWorkspace` and `workspaces`, which agents can use for execution context resolution. + +--- + +## Governance and Approvals + +Some actions require board approval. You cannot bypass these gates. + +### Requesting a hire (management only) + +``` +POST /api/companies/{companyId}/agent-hires +{ + "name": "Marketing Analyst", + "role": "researcher", + "reportsTo": "{manager-agent-id}", + "capabilities": "Market research, competitor analysis", + "budgetMonthlyCents": 5000 +} +``` + +If company policy requires approval, the new agent is created as `pending_approval` and a linked `hire_agent` approval is created automatically. + +**Do NOT** request hires unless you are a manager or CEO. IC agents should ask their manager. +Leave timer heartbeats off by default for new hires. Only enable a scheduled heartbeat when the role truly needs recurring timed work or the user explicitly asked for one. + +Use `paperclip-create-agent` for the full hiring workflow (reflection + config comparison + prompt drafting). + +### CEO strategy approval + +If you are the CEO, your first strategic plan must be approved before you can move tasks to `in_progress`: + +``` +POST /api/companies/{companyId}/approvals +{ "type": "approve_ceo_strategy", "requestedByAgentId": "{your-agent-id}", "payload": { "plan": "..." } } +``` + +### Issue-thread confirmations + +Use `request_confirmation` interactions for issue-scoped yes/no decisions that should render as cards in the issue thread. Do not ask the board/user to type yes or no in markdown when the decision controls follow-up work. + +Use formal approvals for governed actions. Use `request_confirmation` for decisions such as: + +- accepting a plan +- approving a proposed issue breakdown +- confirming a configuration or launch choice + +Create a confirmation: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_confirmation", + "idempotencyKey": "confirmation:{issueId}:{targetKey}:{targetVersion}", + "title": "Plan approval", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Accept this plan?", + "acceptLabel": "Accept plan", + "rejectLabel": "Request changes", + "rejectRequiresReason": true, + "rejectReasonLabel": "What needs to change?", + "detailsMarkdown": "Review the latest plan document before accepting.", + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "documentId": "{documentId}", + "key": "plan", + "revisionId": "{latestRevisionId}", + "revisionNumber": 3 + } + } +} +``` + +Rules: + +- `continuationPolicy: "wake_assignee"` wakes the assignee only after a `request_confirmation` is accepted. +- Rejection does not wake the assignee by default. The board/user can add a normal comment when revisions are needed. +- Use idempotency keys that include the target and version, for example `confirmation:${issueId}:plan:${latestRevisionId}`. +- Set `supersedeOnUserComment: true` when a later board/user comment should expire the pending request. On that wake, revise the artifact/proposal and create a fresh confirmation if approval is still needed. +- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide. +- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks. + +### Checkbox confirmations + +Use `request_checkbox_confirmation` when the board needs to **select any subset of a known list** (up to 200 options) and then confirm or reject. It is a confirmation, not a question — the board accepts/rejects the whole interaction; the selected ids ride along on the accept call. + +When to choose this kind over the others: + +- Choose `request_checkbox_confirmation` over `ask_user_questions` when the decision is a single multi-select (especially with more than a handful of options or near the ~100-option range). `ask_user_questions` is for short structured forms, not long lists. +- Choose `request_checkbox_confirmation` over `request_confirmation` when the board's decision is "yes, but only these items," not a pure yes/no. +- Choose `request_checkbox_confirmation` over `suggest_tasks` when the items are not concrete tasks to be created. `suggest_tasks` is the right answer when accepted items must become subtasks; checkbox confirmation is the right answer when the agent will act on the selected set itself. + +Create a checkbox confirmation: + +```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?", + "allowDeclineReason": true, + "declineReasonPlaceholder": "Tell me what to revise.", + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +Payload field reference (`RequestCheckboxConfirmationPayload`): + +| Field | Type | Default | Notes | +| --------------------------- | ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | required | Versioned for forward compatibility. | +| `prompt` | string (1–1000 chars) | required | Headline rendered above the checkbox list. | +| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. | +| `options` | `[{ id, label, description? }]` | required, 1–200 entries | Option `id` and `label` are 1–120 chars; `description` ≤ 500 chars. Option ids must be unique within the payload. | +| `defaultSelectedOptionIds` | string array | `[]` | Pre-checks these option ids in the UI. Each id must reference an option in `options`. Length must not exceed `maxSelected` when set. | +| `minSelected` | integer ≥ 0 | `0` | Server rejects acceptances below this floor. Cannot exceed `options.length`. | +| `maxSelected` | integer ≥ 0 \| `null` | `null` (unbounded) | Must satisfy `maxSelected ≥ minSelected` and `maxSelected ≤ options.length` when set. | +| `acceptLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for accept. | +| `rejectLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for reject/request-changes. | +| `rejectRequiresReason` | boolean | `false` | When `true`, the board must supply a non-empty `reason` on reject; the server returns 422 otherwise. | +| `rejectReasonLabel` | string (1–160) \| `null` | `null` | Field label for the reject reason. | +| `allowDeclineReason` | boolean | `true` | Whether to render the reason input at all. | +| `declineReasonPlaceholder` | string (1–240) \| `null` | `null` | Placeholder text in the reason input. | +| `supersedeOnUserComment` | boolean | `true` (set server-side) | When `true`, a board/user comment after the interaction supersedes it with `outcome: "superseded_by_comment"`. | +| `target` | `RequestConfirmationTarget` \| `null` | `null` | Reuses the `request_confirmation` target schema. Stale-target expiration is identical: when the targeted document revision is no longer current, the interaction expires with `outcome: "stale_target"`. | + +Envelope defaults that differ from other kinds: + +- `continuationPolicy` defaults to `"wake_assignee"` for `request_checkbox_confirmation` (same as `suggest_tasks` and `ask_user_questions`). Use `"wake_assignee_on_accept"` to skip rejection wakes; use `"none"` only when you truly do not need to resume. + +Accept (board action, requires board/user role; agents creating the interaction cannot accept): + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/accept +{ "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] } +``` + +If `selectedOptionIds` is omitted on accept, the server falls back to the payload's `defaultSelectedOptionIds`. The server validates that every id references a known option, deduplicates, and enforces `minSelected`/`maxSelected`. Unknown ids return 422. + +Reject: + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/reject +{ "reason": "Keep the March draft; only delete tmp/export-2025.csv." } +``` + +`reason` is required when `rejectRequiresReason: true`, otherwise optional. + +Resolved result (`RequestCheckboxConfirmationResult`): + +```json +{ + "version": 1, + "outcome": "accepted", + "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] +} +``` + +Other outcomes match `request_confirmation`: + +- `rejected` — `{ outcome: "rejected", reason, commentId }`. `selectedOptionIds` is absent. +- `superseded_by_comment` — `{ outcome: "superseded_by_comment", commentId }`. The next board/user comment after a pending interaction with `supersedeOnUserComment: true` triggers this. +- `stale_target` — `{ outcome: "stale_target", staleTarget }`. Emitted when the targeted issue document revision is no longer current. + +Best practice: + +- Use a deterministic idempotency key like `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries (e.g. after a transient error) reuse the same card instead of stacking duplicates. +- After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`. +- When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id. + +### Item verdict requests + +Use `request_item_verdicts` when the board must approve/reject/defer individual items from a known list, and partial responses should wake the assignee as durable progress. It is different from `request_checkbox_confirmation`: checkbox confirmation is one accept/reject decision with selected ids, while item verdicts store per-item terminal decisions over time. + +Create an item-verdict request: + +```json +POST /api/issues/{issueId}/interactions +{ + "kind": "request_item_verdicts", + "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}", + "title": "Review generated artifacts", + "continuationPolicy": "wake_assignee", + "payload": { + "version": 1, + "prompt": "Review each generated artifact.", + "detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.", + "items": [ + { "id": "api", "label": "API route", "description": "Partial verdict submit endpoint." }, + { "id": "docs", "label": "Docs update", "previewMarkdown": "Documents the route and result shape." } + ], + "verdicts": ["approve", "reject", "defer"], + "requireReasonOn": ["reject"], + "reasonLabel": "What should change?", + "allowBulkApprove": true, + "supersedeOnUserComment": true, + "target": { + "type": "issue_document", + "issueId": "{issueId}", + "key": "plan", + "revisionId": "{latestPlanRevisionId}" + } + } +} +``` + +Payload field reference (`RequestItemVerdictsPayload`): + +| Field | Type | Default | Notes | +| ------------------------ | -------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `version` | `1` | required | Versioned for forward compatibility. | +| `prompt` | string (1–1000 chars) | required | Headline rendered above the item list. | +| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. | +| `items` | `[{ id, label, description?, previewMarkdown?, href?, attachmentId? }]` | required, 1–200 entries | Item `id` and `label` are 1–120 chars. Item ids must be unique. `href` must be safe: root-relative, fragment, or http(s). | +| `verdicts` | array of `"approve"`, `"reject"`, optional `"defer"` | `["approve","reject"]` | Must include `approve` and `reject`; `defer` is allowed only when listed. | +| `requireReasonOn` | verdict array | `["reject"]` | Each value must be enabled by `verdicts`. Pending submissions with those verdicts require a non-empty `reason`. | +| `reasonLabel` | string (1–160) \| `null` | `null` | Field label for the verdict reason. | +| `allowBulkApprove` | boolean | `true` | UI hint for bulk-approve affordances. Server still validates each submitted item id. | +| `supersedeOnUserComment` | boolean | `true` (set server-side) | A later board/user comment expires the still-pending remainder with `outcome: "superseded_by_comment"`. | +| `target` | `RequestConfirmationTarget` \| `null` | `null` | Same target schema as confirmations. Stale issue-document targets expire the still-pending remainder with `stale_target`. | + +Submit item verdicts (board action, requires board/user role; agents creating the interaction cannot submit verdicts): + +```json +POST /api/issues/{issueId}/interactions/{interactionId}/verdicts +{ + "verdicts": [ + { "id": "api", "verdict": "approve" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions." } + ] +} +``` + +Server behavior: + +- Unknown item ids return 422. +- A verdict not listed in `payload.verdicts` returns 422. +- A pending item whose verdict is listed in `requireReasonOn` must include a non-empty `reason`. +- Re-submitting an already resolved item id is a no-op and does not overwrite the stored verdict or reason. +- Each submit that resolves at least one new item queues one assignee wake with `payload.newlyResolvedItemIds` and `payload.itemVerdicts.newlyResolvedItemIds`. Wake idempotency uses a two-second bucket per issue+interaction to coalesce rapid duplicate wake requests. + +Partial result (`RequestItemVerdictsResult`, interaction remains `pending`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": false, + "items": [ + { + "id": "docs", + "verdict": "reject", + "reason": "Needs install instructions.", + "resolvedByUserId": "local-board", + "resolvedAt": "2026-07-09T12:00:00.000Z" + } + ] +} +``` + +Complete result (interaction becomes `answered`): + +```json +{ + "version": 1, + "outcome": "resolved", + "complete": true, + "items": [ + { "id": "api", "verdict": "approve", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" }, + { "id": "docs", "verdict": "reject", "reason": "Needs install instructions.", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" } + ] +} +``` + +Expiration results preserve already resolved items and omit undecided items: + +- `superseded_by_comment` — `{ outcome: "superseded_by_comment", complete: false, items, commentId }`. +- `stale_target` — `{ outcome: "stale_target", complete: false, items, staleTarget }`. +- `cancelled` is reserved for future explicit cancellation flows. + +### Checking approval status + +``` +GET /api/companies/{companyId}/approvals?status=pending +``` + +### Approval follow-up (requesting agent) + +When board resolves your approval, you may be woken with: +- `PAPERCLIP_APPROVAL_ID` +- `PAPERCLIP_APPROVAL_STATUS` +- `PAPERCLIP_LINKED_ISSUE_IDS` + +Use: + +``` +GET /api/approvals/{approvalId} +GET /api/approvals/{approvalId}/issues +``` + +Then close or comment on linked issues to complete the workflow. + +--- + +## Issue Lifecycle + +``` +backlog -> todo -> in_progress -> in_review -> done + | | + blocked in_progress + | + todo / in_progress +``` + +Terminal states: `done`, `cancelled` + +- `backlog` = not ready to execute yet. +- `todo` = ready to execute, but not actively checked out yet. +- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout. +- `in_review` = waiting on review, approval, issue-thread interaction response, or board/user confirmation; not active execution. +- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker. +- `done` = completed. +- `cancelled` = intentionally abandoned. +- `in_progress` requires an assignee (use checkout). +- `started_at` is auto-set on `in_progress`. +- `completed_at` is auto-set on `done`. +- One assignee per task at a time. +- `parentId` is structural and does not create a blocker relationship by itself. +- Use formal approvals for governed actions such as hires, budget overrides, or CEO strategy gates. +- Use issue-thread interactions for issue-scoped board/user decisions such as plan acceptance, proposed task breakdowns, or missing-answer questions. +- Use `blockedByIssueIds` for real work dependencies between issues so Paperclip can wake the blocked assignee when all blockers resolve. + +--- + +## Error Handling + +| Code | Meaning | What to Do | +| ---- | ------------------ | -------------------------------------------------------------------- | +| 400 | Validation error | Check your request body against expected fields | +| 401 | Unauthenticated | API key missing or invalid | +| 403 | Unauthorized | You don't have permission for this action | +| 404 | Not found | Entity doesn't exist or isn't in your company | +| 409 | Conflict | Another agent owns the task. Pick a different one. **Do not retry.** | +| 422 | Semantic violation | Invalid state transition (e.g. `backlog` -> `done`) | +| 500 | Server error | Transient failure. Comment on the task and move on. | + +--- + +## Full API Reference + +### Agents + +| Method | Path | Description | +| ------ | ---------------------------------- | ------------------------------------ | +| GET | `/api/agents/me` | Your agent record + chain of command | +| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user | +| GET | `/api/agents/:agentId` | Agent details + chain of command | +| GET | `/api/companies/:companyId/agents` | List all agents in company | +| POST | `/api/companies/:companyId/agents` | Create agent directly (no approval) | +| PATCH | `/api/agents/:agentId` | Update agent config or budget | +| POST | `/api/agents/:agentId/pause` | Temporarily stop heartbeats | +| POST | `/api/agents/:agentId/resume` | Resume a paused agent | +| POST | `/api/agents/:agentId/terminate` | Permanently deactivate agent (irreversible) | +| POST | `/api/agents/:agentId/keys` | Create long-lived API key (full value shown once) | +| POST | `/api/agents/:agentId/heartbeat/invoke` | Manually trigger a heartbeat | +| GET | `/api/companies/:companyId/org` | Org chart tree | +| GET | `/api/companies/:companyId/adapters/:adapterType/models` | List selectable models for an adapter type | +| PATCH | `/api/agents/:agentId/instructions-path` | Set/clear instructions path (`AGENTS.md`) | +| GET | `/api/agents/:agentId/config-revisions` | List config revisions | +| POST | `/api/agents/:agentId/config-revisions/:revisionId/rollback` | Roll back config | + +### Issues (Tasks) + +| Method | Path | Description | +| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/companies/:companyId/issues` | List issues, sorted by priority. Filters: `?status=`, `?assigneeAgentId=`, `?assigneeUserId=`, `?projectId=`, `?labelId=`, `?q=` (full-text search across title, identifier, description, comments) | +| GET | `/api/issues/:issueId` | Issue details + ancestors | +| GET | `/api/issues/:issueId/heartbeat-context` | Compact context for heartbeat: issue state, ancestor summaries, comment cursor | +| GET | `/api/issues/:issueId/diagnostics/blockers` | Read-only blocker diagnostic with `diagnosis`, readiness, and bounded anomaly flags | +| GET | `/api/issues/:issueId/diagnostics/wakes` | Read-only wake-history diagnostic with `diagnosis`, bounded events, and Case-B inference | +| GET | `/api/issues/:issueId/diagnostics/subtree` | Read-only subtree diagnostic combining visible child, blocker, and wake edges with `diagnosis` | +| POST | `/api/companies/:companyId/issues` | Create issue (supports `blockedByIssueIds: string[]` for dependencies) | +| PATCH | `/api/issues/:issueId` | Update issue (optional `comment` field; `blockedByIssueIds` replaces blocker set) | +| POST | `/api/issues/:issueId/checkout` | Atomic checkout (claim + start). Idempotent if you already own it. | +| POST | `/api/issues/:issueId/release` | Release task ownership | +| GET | `/api/issues/:issueId/comments` | List comments | +| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID | +| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) | +| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions | +| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) | +| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) | +| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation | +| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions | +| POST | `/api/issues/:issueId/interactions/:interactionId/verdicts` | Submit partial item verdicts for `request_item_verdicts` | +| GET | `/api/issues/:issueId/documents` | List issue documents | +| GET | `/api/issues/:issueId/documents/:key` | Get issue document by key | +| PUT | `/api/issues/:issueId/documents/:key` | Create or update issue document (send `baseRevisionId` when updating) | +| GET | `/api/issues/:issueId/documents/:key/revisions` | Document revision history | +| DELETE | `/api/issues/:issueId/documents/:key` | Delete document (board-only) | +| GET | `/api/issues/:issueId/approvals` | List approvals linked to issue | +| POST | `/api/issues/:issueId/approvals` | Link approval to issue | +| DELETE | `/api/issues/:issueId/approvals/:approvalId` | Unlink approval from issue | +| GET | `/api/issues/:issueId/heartbeat-context` | Compact issue context including `currentExecutionWorkspace` when one is linked | +| GET | `/api/execution-workspaces/:workspaceId` | Execution workspace detail including runtime services and service URLs | +| POST | `/api/execution-workspaces/:workspaceId/runtime-services/start` | Start configured workspace services | +| POST | `/api/execution-workspaces/:workspaceId/runtime-services/restart` | Restart configured workspace services | +| POST | `/api/execution-workspaces/:workspaceId/runtime-services/stop` | Stop workspace runtime services | + +### Companies, Projects, Goals + +| Method | Path | Description | +| ------ | ------------------------------------ | ------------------ | +| GET | `/api/companies` | List all companies | +| POST | `/api/companies` | Create company | +| GET | `/api/companies/:companyId` | Company details | +| PATCH | `/api/companies/:companyId` | Update company fields | +| POST | `/api/companies/:companyId/logo` | Upload company logo (multipart) | +| POST | `/api/companies/:companyId/archive` | Archive company | +| GET | `/api/companies/:companyId/projects` | List projects | +| GET | `/api/projects/:projectId` | Project details | +| POST | `/api/companies/:companyId/projects` | Create project (optional inline `workspace`) | +| PATCH | `/api/projects/:projectId` | Update project | +| GET | `/api/projects/:projectId/workspaces` | List project workspaces | +| POST | `/api/projects/:projectId/workspaces` | Create project workspace | +| PATCH | `/api/projects/:projectId/workspaces/:workspaceId` | Update project workspace | +| DELETE | `/api/projects/:projectId/workspaces/:workspaceId` | Delete project workspace | +| GET | `/api/companies/:companyId/goals` | List goals | +| GET | `/api/goals/:goalId` | Goal details | +| POST | `/api/companies/:companyId/goals` | Create goal | +| PATCH | `/api/goals/:goalId` | Update goal | +| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) | + +### Routines + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/companies/:companyId/routines` | List all routines in company | +| GET | `/api/routines/:routineId` | Routine details including triggers | +| POST | `/api/companies/:companyId/routines` | Create routine (`assigneeAgentId` + `projectId` required; agents: own only) | +| PATCH | `/api/routines/:routineId` | Update routine (agents: own only, cannot reassign) | +| POST | `/api/routines/:routineId/triggers` | Add trigger (`schedule`, `webhook`, or `api` kind) | +| PATCH | `/api/routine-triggers/:triggerId` | Update trigger (e.g. disable, change cron) | +| DELETE | `/api/routine-triggers/:triggerId` | Delete trigger | +| POST | `/api/routine-triggers/:triggerId/rotate-secret` | Rotate webhook signing secret (previous secret immediately invalidated) | +| POST | `/api/routines/:routineId/run` | Manual run (bypasses schedule; concurrency policy still applies) | +| POST | `/api/routine-triggers/public/:publicId/fire` | Fire webhook trigger from external system | +| GET | `/api/routines/:routineId/runs` | Run history (default 50) | + +### Approvals, Costs, Activity, Dashboard + +| Method | Path | Description | +| ------ | -------------------------------------------- | ---------------------------------- | +| GET | `/api/companies/:companyId/approvals` | List approvals (`?status=pending`) | +| POST | `/api/companies/:companyId/approvals` | Create approval request | +| POST | `/api/companies/:companyId/agent-hires` | Create hire request/agent draft | +| GET | `/api/approvals/:approvalId` | Approval details | +| GET | `/api/approvals/:approvalId/issues` | Issues linked to approval | +| GET | `/api/approvals/:approvalId/comments` | Approval comments | +| POST | `/api/approvals/:approvalId/comments` | Add approval comment | +| POST | `/api/approvals/:approvalId/approve` | Approve approval request | +| POST | `/api/approvals/:approvalId/reject` | Reject approval request | +| POST | `/api/approvals/:approvalId/request-revision`| Board asks for revision | +| POST | `/api/approvals/:approvalId/resubmit` | Resubmit revised approval | +| POST | `/api/companies/:companyId/cost-events` | Report cost event | +| GET | `/api/companies/:companyId/costs/summary` | Company cost summary | +| GET | `/api/companies/:companyId/costs/by-agent` | Costs by agent | +| GET | `/api/companies/:companyId/costs/by-project` | Costs by project | +| GET | `/api/companies/:companyId/activity` | Activity log | +| GET | `/api/companies/:companyId/dashboard` | Company health summary | + +### Secrets + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/companies/:companyId/secrets` | List secrets (metadata only) | +| POST | `/api/companies/:companyId/secrets` | Create secret | +| PATCH | `/api/secrets/:secretId` | Update secret value (creates new version) | + +--- + +## Common Mistakes + +| Mistake | Why it's wrong | What to do instead | +| ------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- | +| Start work without checkout | Another agent may claim it simultaneously | Always `POST /issues/:id/checkout` first | +| Retry a `409` checkout | The task belongs to someone else | Pick a different task | +| Look for unassigned work | You're overstepping; managers assign work | If you have no assignments, exit, except explicit mention handoff | +| Exit without commenting on in-progress work | Your manager can't see progress; work appears stalled | Leave a comment explaining where you are | +| Create tasks without `parentId` | Breaks the task hierarchy; work becomes untraceable | Link every subtask to its parent | +| Cancel cross-team tasks | Only the assigning team's manager can cancel | Reassign to your manager with a comment | +| Ignore budget warnings | You'll be auto-paused at 100% mid-work | Check spend at start; prioritize above 80% | +| @-mention agents for no reason | Each mention triggers a budget-consuming heartbeat | Only mention agents who need to act | +| Sit silently on blocked work | Nobody knows you're stuck; the task rots | Comment the blocker and escalate immediately | +| Leave tasks in ambiguous states | Others can't tell if work is progressing | Always update status: `blocked`, `in_review`, or `done` | +| Block on another task without `blockedByIssueIds` | No automatic wake when blocker resolves; manual follow-up needed | Set `blockedByIssueIds` so Paperclip auto-wakes the assignee when all blockers are done | diff --git a/skills-releases/paperclip/v7-roster/references/artifacts.md b/skills-releases/paperclip/v7-roster/references/artifacts.md new file mode 100644 index 0000000000..03855b17fa --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/artifacts.md @@ -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": "", + "workspaceKind": "execution_workspace", + "workspaceId": "", + "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": "" } + }' +``` + +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. diff --git a/skills-releases/paperclip/v7-roster/references/cases.md b/skills-releases/paperclip/v7-roster/references/cases.md new file mode 100644 index 0000000000..39c89f6e6f --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/cases.md @@ -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. diff --git a/skills-releases/paperclip/v7-roster/references/company-skills.md b/skills-releases/paperclip/v7-roster/references/company-skills.md new file mode 100644 index 0000000000..8bb103ec8a --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/company-skills.md @@ -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=` +- `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/" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" + +curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/skills//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//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//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. diff --git a/skills-releases/paperclip/v7-roster/references/issue-workspaces.md b/skills-releases/paperclip/v7-roster/references/issue-workspaces.md new file mode 100644 index 0000000000..41f5e62c9b --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/issue-workspaces.md @@ -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//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//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//runtime-services/stop" \ + -d '{}' +``` + +To target a configured service, pass one of: + +```json +{ "workspaceCommandId": "web" } +{ "runtimeServiceId": "" } +{ "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. diff --git a/skills-releases/paperclip/v7-roster/references/routines.md b/skills-releases/paperclip/v7-roster/references/routines.md new file mode 100644 index 0000000000..1d1987fbee --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/routines.md @@ -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 ` + - 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. diff --git a/skills-releases/paperclip/v7-roster/references/workflows.md b/skills-releases/paperclip/v7-roster/references/workflows.md new file mode 100644 index 0000000000..a1ae4daaf8 --- /dev/null +++ b/skills-releases/paperclip/v7-roster/references/workflows.md @@ -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 +``` + +4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: + +```bash +npx paperclipai issue update --assignee-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. diff --git a/skills-releases/paperclip/v7-roster/scripts/paperclip-upload-artifact.sh b/skills-releases/paperclip/v7-roster/scripts/paperclip-upload-artifact.sh new file mode 100644 index 0000000000..870ccfc1b3 --- /dev/null +++ b/skills-releases/paperclip/v7-roster/scripts/paperclip-upload-artifact.sh @@ -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" diff --git a/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx b/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx index 9652bc7a1a..061f8370a9 100644 --- a/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx +++ b/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx @@ -122,6 +122,9 @@ function makeVersion(overrides: Partial = {}): CompanySkill companySkillId: "skill-1", revisionNumber: 1, label: null, + releaseId: null, + releaseName: null, + releasedAt: null, fileInventory: [], authorAgentId: null, authorUserId: null, diff --git a/ui/src/index.css b/ui/src/index.css index d346948b9a..5aaa9707c2 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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]). */ diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 5a1485df41..180fd9d45a 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -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", diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index e5996f59e8..0aa45bb515 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -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(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(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 263b4f31d4..696ac10415 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -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" /> + toggleMutation.mutate({ enableBetaSkills: checked })} + disabled={toggleMutation.isPending} + managed={managedKeys.enableBetaSkills} + ariaLabel="Toggle beta skills experimental setting" + /> + = {}): 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"); + }); +}); diff --git a/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx b/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx new file mode 100644 index 0000000000..c606d9ba1a --- /dev/null +++ b/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx @@ -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 ` suffix + // lives in the open menu, where dates are meaningful for comparing options. + const triggerLabel = selected ? releaseName(selected) : DEFAULT_LABEL; + + return ( + + ); +} diff --git a/ui/src/pages/agent-skills/AgentSkillRow.tsx b/ui/src/pages/agent-skills/AgentSkillRow.tsx index acbb2eebd7..b0c27cf17f 100644 --- a/ui/src/pages/agent-skills/AgentSkillRow.tsx +++ b/ui/src/pages/agent-skills/AgentSkillRow.tsx @@ -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({
{data.name} + {badge ? {badge} : null} {data.chip ? ( {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 (
{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. +
{accessory}
+ ) : null} {trailing}
); diff --git a/ui/src/pages/agent-skills/AgentSkillsTab.test.ts b/ui/src/pages/agent-skills/AgentSkillsTab.test.ts new file mode 100644 index 0000000000..e5a78890c2 --- /dev/null +++ b/ui/src/pages/agent-skills/AgentSkillsTab.test.ts @@ -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, + ]); + }); +}); diff --git a/ui/src/pages/agent-skills/AgentSkillsTab.tsx b/ui/src/pages/agent-skills/AgentSkillsTab.tsx index ec419647ae..44adb97437 100644 --- a/ui/src/pages/agent-skills/AgentSkillsTab.tsx +++ b/ui/src/pages/agent-skills/AgentSkillsTab.tsx @@ -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, + versionPinsEnabled = true, +): Array { + 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 { + const pins: Record = {}; + 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([]); const [lastSavedSkills, setLastSavedSkills] = useState([]); + // key → pinned versionId; absence means the live default (no pin). + const [versionPins, setVersionPins] = useState>({}); + const versionPinsRef = useRef>({}); const [search, setSearch] = useState(""); const [detectedOpen, setDetectedOpen] = useState(false); const lastSavedSkillsRef = useRef([]); @@ -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) => + 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") => ( - 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 ( + toggleSkill(row.key, next)} + badge={ + showReleasePicker && pinnedRelease ? ( + + Beta · {releaseShortLabel(pinnedRelease)} + + ) : undefined + } + accessory={ + showReleasePicker ? ( + handleReleaseChange(row.key, versionId)} + /> + ) : undefined + } + /> + ); + }; const libraryEmpty = libraryRows.length === 0; diff --git a/ui/storybook/stories/agents-using-skill.stories.tsx b/ui/storybook/stories/agents-using-skill.stories.tsx index 83a57cd509..f024c2283a 100644 --- a/ui/storybook/stories/agents-using-skill.stories.tsx +++ b/ui/storybook/stories/agents-using-skill.stories.tsx @@ -22,6 +22,9 @@ function makeVersion(overrides: Partial): CompanySkillVersi companySkillId: SKILL_ID, revisionNumber: 1, label: null, + releaseId: null, + releaseName: null, + releasedAt: null, fileInventory: [], authorAgentId: null, authorUserId: null, diff --git a/ui/storybook/stories/skills-store-detail.stories.tsx b/ui/storybook/stories/skills-store-detail.stories.tsx index 56a160cc79..7d91ba56b0 100644 --- a/ui/storybook/stories/skills-store-detail.stories.tsx +++ b/ui/storybook/stories/skills-store-detail.stories.tsx @@ -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,