From 5af49cb4776f9cdce32046d903b4e8a0f7d298fe Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 13:51:39 -0700 Subject: [PATCH] feat(server): CEO agents get the core paperclip skills by default (#12138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Each agent's runtime only receives skills listed in its own desired-skill set; the company library alone does nothing for an agent > - Every CEO creation path (first-run wizard hire, New Agent first-agent flow, cloud onboarding seed) creates the CEO with an empty desired-skill set > - The default CEO instructions tell the agent to use the core paperclip skills, so a fresh CEO contradicts its own instructions and reports its toolkit as "not installed" > - This pull request unions the core skill keys into every skills-capable CEO hire/create and into the onboarding-seeded CEO's adapter config > - The benefit is that a new CEO can actually do what its instructions describe, and stops telling users that installed skills do not exist ## Linked Issues or Issue Description **What existing behavior does this improve?** Creating the first lead agent (role `ceo`) via hire, create, or the cloud onboarding seed. **Subsystem affected** Server — agent hire/create routes (`server/src/routes/agents.ts`), onboarding seed (`server/src/services/onboarding-seed.ts`), company skills service constant (`server/src/services/company-skills.ts`). **Current behavior** A CEO created by the wizard, the New Agent page, or the onboarding seed has no `paperclipSkillSync` block. Its runtime mounts zero skills. Its default instructions (`server/src/onboarding-assets/ceo/AGENTS.md`, `HEARTBEAT.md`) tell it to use `paperclip-create-agent`, `para-memory-files`, and the paperclip coordination skill. The agent then reports these skills as not installed. **Proposed behavior** When the new agent's role is `ceo` and its adapter supports skill sync, the hire and create routes union the five bundled `paperclipai/paperclip/*` skill keys into the requested desired-skill set. The onboarding seed writes the same preference into the seeded CEO's adapter config. Explicit requests win over defaults for the same key. Non-CEO agents are unchanged. Any default stays removable through `POST /agents/:id/skills/sync`. **Breaking changes** None. The default is additive, applies only to role `ceo` on skills-capable adapters, and the bundled skills are guaranteed present in every company library by `ensureSkillInventoryCurrent`. ## What Changed - New exported constant `PAPERCLIP_CORE_SKILL_KEYS` in `server/src/services/company-skills.ts` (the five bundled `paperclipai/paperclip/*` keys). - `defaultRoleSkillSelections` + `withDefaultRoleSkillSelections` helpers in `server/src/routes/agents.ts`, applied in both the hire and create routes before `resolveDesiredSkillAssignment(..., "add")`. - `server/src/services/onboarding-seed.ts` builds the seeded CEO's adapter config with `writePaperclipSkillSyncPreference` instead of `{}` when the seeded adapter supports skills. ## Verification - `cd server && npx vitest run src/__tests__/agent-skills-routes.test.ts` — 32 tests pass (three new: CEO default set, union with a requested skill, non-CEO untouched). - `cd server && npx vitest run src/__tests__/onboarding-seed-route.test.ts` — 14 tests pass (seeded CEO adapter config assertion added). - `cd server && npx vitest run src/__tests__/agent-permissions-routes.test.ts` — 54 tests pass. - `cd server && pnpm run typecheck` — clean. ## Risks - Existing CEOs are not modified; only newly created ones get the defaults. An operator who wants a minimal CEO can remove the skills after creation with the skills sync (mode `remove`), and that removal sticks. Adapters without skill support are skipped, so the change is inert there. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking and tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/__tests__/agent-skills-routes.test.ts | 68 +++++++++++++++++++ .../__tests__/onboarding-seed-route.test.ts | 14 ++++ server/src/routes/agents.ts | 38 ++++++++++- server/src/services/company-skills.ts | 17 +++++ server/src/services/onboarding-seed.ts | 22 +++++- 5 files changed, 156 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 47379332d3..9e151fcfc2 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1172,6 +1172,74 @@ describe.sequential("agent skill routes", () => { ); }); + it("gives a CEO hire the core paperclip skills when none are requested", async () => { + const res = await request(await createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "First Lead", + role: "ceo", + adapterType: "claude_local", + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: expect.arrayContaining([ + "paperclipai/paperclip/paperclip", + "paperclipai/paperclip/paperclip-board", + "paperclipai/paperclip/paperclip-converting-plans-to-tasks", + "paperclipai/paperclip/paperclip-create-agent", + "paperclipai/paperclip/para-memory-files", + ]), + }), + }), + }), + expect.anything(), + ); + }); + + it("unions requested skills with the CEO defaults instead of replacing them", async () => { + const res = await request(await createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "First Lead", + role: "ceo", + adapterType: "claude_local", + desiredSkills: ["paperclip"], + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + const createInput = mockAgentService.create.mock.calls[0]?.[1] as { + adapterConfig: { paperclipSkillSync: { desiredSkills: string[] } }; + }; + const desired = createInput.adapterConfig.paperclipSkillSync.desiredSkills; + // "paperclip" resolves to its canonical key and dedupes with the default. + expect(desired).toHaveLength(5); + expect(desired).toContain("paperclipai/paperclip/paperclip"); + }); + + it("does not add default skills to non-CEO hires", 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", + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + const createInput = mockAgentService.create.mock.calls[0]?.[1] as { + adapterConfig: Record; + }; + expect(createInput.adapterConfig.paperclipSkillSync).toBeUndefined(); + }); + 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") diff --git a/server/src/__tests__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts index 16059d91dd..384066e545 100644 --- a/server/src/__tests__/onboarding-seed-route.test.ts +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -85,6 +85,20 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => // The seed's free-text role is a job title; the structural role stays `ceo`. expect(companyAgents[0]?.title).toBe("Chief of Staff"); expect(companyAgents[0]?.role).toBe("ceo"); + // A seeded CEO arrives with the core paperclip skills enabled. Skills only + // reach an agent's runtime through its own desired set, and the default + // CEO instructions assume this toolkit. + expect(companyAgents[0]?.adapterConfig).toMatchObject({ + paperclipSkillSync: { + desiredSkills: expect.arrayContaining([ + "paperclipai/paperclip/paperclip", + "paperclipai/paperclip/paperclip-board", + "paperclipai/paperclip/paperclip-converting-plans-to-tasks", + "paperclipai/paperclip/paperclip-create-agent", + "paperclipai/paperclip/para-memory-files", + ]), + }, + }); const companyIssues = await ctx.db.select().from(issues).where(eq(issues.companyId, companyId)); expect(companyIssues).toHaveLength(1); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5b290d4152..d4dd14b6fc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -60,6 +60,7 @@ import { workspaceOperationService, } from "../services/index.js"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; +import { PAPERCLIP_CORE_SKILL_KEYS } from "../services/company-skills.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js"; @@ -2213,6 +2214,33 @@ export function agentRoutes( }; } + // The default CEO instructions assume the core paperclip skills (board + // coordination, planning, hiring, memory). Union them into every + // skills-capable CEO hire/create so a fresh CEO never starts with an empty + // desired-skill set that contradicts its own instructions. Callers can still + // remove any of them afterwards via the per-agent skills sync. + function defaultRoleSkillSelections( + role: string | null | undefined, + adapterType: string, + ): AgentDesiredSkillEntry[] | undefined { + if (role !== "ceo") return undefined; + const adapter = findActiveServerAdapter(adapterType); + if (!adapter?.listSkills && !adapter?.syncSkills) return undefined; + return PAPERCLIP_CORE_SKILL_KEYS.map((key) => ({ key, versionId: null })); + } + + function withDefaultRoleSkillSelections( + requested: AgentDesiredSkillEntry[] | undefined, + defaults: AgentDesiredSkillEntry[] | undefined, + ): AgentDesiredSkillEntry[] | undefined { + if (!defaults) return requested; + if (!requested) return defaults; + const merged = new Map(defaults.map((entry) => [entry.key, entry])); + // An explicit request wins over a default for the same key (version pins). + for (const entry of requested) merged.set(entry.key, entry); + return Array.from(merged.values()); + } + function normalizeDesiredSkillSelections( requestedDesiredSkills: Array | undefined, ): AgentDesiredSkillEntry[] | undefined { @@ -3335,7 +3363,10 @@ export function agentRoutes( companyId, hireInput.adapterType, requestedAdapterConfig, - normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + withDefaultRoleSkillSelections( + normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + defaultRoleSkillSelections(hireInput.role, hireInput.adapterType), + ), "add", ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ @@ -3551,7 +3582,10 @@ export function agentRoutes( companyId, createInput.adapterType, requestedAdapterConfig, - normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + withDefaultRoleSkillSelections( + normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + defaultRoleSkillSelections(createInput.role, createInput.adapterType), + ), "add", ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 22348d8764..cfa474d477 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -618,6 +618,23 @@ function readCanonicalSkillKey(frontmatter: Record, metadata: R ); } +/** + * The bundled operating skills the default agent instructions assume every + * lead agent has (coordination, board usage, planning, hiring, memory). A + * seeded or hired CEO must arrive with these enabled: an agent's runtime only + * receives skills in its own desired set, so a CEO with an empty set + * truthfully reports these as not installed while its instructions tell it to + * use them. Mirrors the repo-root `skills/` bundle that + * `ensureSkillInventoryCurrent` imports into every company library. + */ +export const PAPERCLIP_CORE_SKILL_KEYS = [ + "paperclipai/paperclip/paperclip", + "paperclipai/paperclip/paperclip-board", + "paperclipai/paperclip/paperclip-converting-plans-to-tasks", + "paperclipai/paperclip/paperclip-create-agent", + "paperclipai/paperclip/para-memory-files", +] as const; + function deriveCanonicalSkillKey( companyId: string, input: Pick, diff --git a/server/src/services/onboarding-seed.ts b/server/src/services/onboarding-seed.ts index eba8a0d143..ae7b7fcb3a 100644 --- a/server/src/services/onboarding-seed.ts +++ b/server/src/services/onboarding-seed.ts @@ -2,7 +2,10 @@ import { and, eq, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, companyOnboardingSeeds, goals, issues, projects } from "@paperclipai/db"; import type { ApplyOnboardingSeed } from "@paperclipai/shared"; +import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import { findActiveServerAdapter } from "../adapters/registry.js"; import { agentService } from "./agents.js"; +import { PAPERCLIP_CORE_SKILL_KEYS } from "./company-skills.js"; import { goalService } from "./goals.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; @@ -36,6 +39,23 @@ function seededAgentAdapterType() { || FALLBACK_SEEDED_AGENT_ADAPTER_TYPE; } +/** + * Adapter config for the seeded CEO. The default CEO instructions tell the + * agent to use the core paperclip skills (hiring, memory, coordination), and + * an agent's runtime only receives skills listed in its own desired set — so + * a seeded CEO with an empty adapter config arrives with zero skills and + * truthfully reports its own toolkit as not installed. Enable the core set + * whenever the seeded adapter supports skill sync. + */ +function seededAgentAdapterConfig(adapterType: string): Record { + const adapter = findActiveServerAdapter(adapterType); + if (!adapter?.listSkills && !adapter?.syncSkills) return {}; + return writePaperclipSkillSyncPreference( + {}, + PAPERCLIP_CORE_SKILL_KEYS.map((key) => ({ key, versionId: null })), + ); +} + /** * Split a free-text mission into a goal title + description the same way the * first-run wizard's `parseOnboardingGoalInput` does: first line is the title, @@ -223,7 +243,7 @@ export function onboardingSeedService(db: Db) { role: SEEDED_AGENT_ROLE, title: agentRole, adapterType: seededAgentAdapterType(), - adapterConfig: {}, + adapterConfig: seededAgentAdapterConfig(seededAgentAdapterType()), runtimeConfig: {}, permissions: {}, status: "idle",