From 480174367d3a30fd9afaf364380b9e4637478845 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 13:18:48 -0500 Subject: [PATCH] Add company skill assignment to agent create and hire flows Co-Authored-By: Paperclip --- packages/shared/src/validators/agent.ts | 1 + .../src/__tests__/agent-skills-routes.test.ts | 125 ++++++++++++++- .../__tests__/company-skills-routes.test.ts | 113 +++++++++++++ server/src/routes/agents.ts | 95 +++++++++-- server/src/routes/company-skills.ts | 51 +++++- server/src/services/company-skills.ts | 84 ++++++++-- skills/paperclip-create-agent/SKILL.md | 3 + .../references/api-reference.md | 12 +- skills/paperclip/SKILL.md | 15 ++ skills/paperclip/references/company-skills.md | 151 ++++++++++++++++++ ui/src/components/ApprovalPayload.tsx | 26 +++ ui/src/pages/NewAgent.tsx | 58 +++++++ 12 files changed, 699 insertions(+), 35 deletions(-) create mode 100644 server/src/__tests__/company-skills-routes.test.ts create mode 100644 skills/paperclip/references/company-skills.md diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 500910cc..1dcc7dbd 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -50,6 +50,7 @@ export const createAgentSchema = z.object({ icon: z.enum(AGENT_ICON_NAMES).optional().nullable(), reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), + desiredSkills: z.array(z.string().min(1)).optional(), adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"), adapterConfig: adapterConfigSchema.optional().default({}), runtimeConfig: z.record(z.unknown()).optional().default({}), diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index a6b35520..f0b6c499 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -7,6 +7,7 @@ import { errorHandler } from "../middleware/index.js"; const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), + create: vi.fn(), resolveByReference: vi.fn(), })); @@ -18,7 +19,9 @@ const mockAccessService = vi.hoisted(() => ({ const mockApprovalService = vi.hoisted(() => ({})); const mockBudgetService = vi.hoisted(() => ({})); const mockHeartbeatService = vi.hoisted(() => ({})); -const mockIssueApprovalService = vi.hoisted(() => ({})); +const mockIssueApprovalService = vi.hoisted(() => ({ + linkManyForApproval: vi.fn(), +})); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockAgentInstructionsService = vi.hoisted(() => ({ getBundle: vi.fn(), @@ -33,6 +36,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({ const mockCompanySkillService = vi.hoisted(() => ({ listRuntimeSkillEntries: vi.fn(), + resolveRequestedSkillKeys: vi.fn(), })); const mockSecretService = vi.hoisted(() => ({ @@ -68,7 +72,22 @@ vi.mock("../adapters/index.js", () => ({ listAdapterModels: vi.fn(), })); -function createApp() { +function createDb(requireBoardApprovalForNewAgents = false) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => [ + { + id: "company-1", + requireBoardApprovalForNewAgents, + }, + ]), + })), + })), + }; +} + +function createApp(db: Record = createDb()) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -81,7 +100,7 @@ function createApp() { }; next(); }); - app.use("/api", agentRoutes({} as any)); + app.use("/api", agentRoutes(db as any)); app.use(errorHandler); return app; } @@ -121,6 +140,14 @@ describe("agent skill routes", () => { requiredReason: "required", }, ]); + mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation( + async (_companyId: string, requested: string[]) => + requested.map((value) => + value === "paperclip" + ? "paperclipai/paperclip/paperclip" + : value, + ), + ); mockAdapter.listSkills.mockResolvedValue({ adapterType: "claude_local", supported: true, @@ -141,7 +168,24 @@ describe("agent skill routes", () => { ...makeAgent("claude_local"), adapterConfig: patch.adapterConfig ?? {}, })); + mockAgentService.create.mockImplementation(async (_companyId: string, input: Record) => ({ + ...makeAgent(String(input.adapterType ?? "claude_local")), + ...input, + adapterConfig: input.adapterConfig ?? {}, + runtimeConfig: input.runtimeConfig ?? {}, + budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0), + permissions: null, + })); + mockApprovalService.create = vi.fn(async (_companyId: string, input: Record) => ({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "pending", + payload: input.payload ?? {}, + })); mockLogActivity.mockResolvedValue(undefined); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(true); }); it("skips runtime materialization when listing Claude skills", async () => { @@ -197,4 +241,79 @@ describe("agent skill routes", () => { }); expect(mockAdapter.syncSkills).toHaveBeenCalled(); }); + + it("canonicalizes desired skill references before syncing", async () => { + mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); + + const res = await request(createApp()) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclip"] }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); + expect(mockAgentService.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + expect.any(Object), + ); + }); + + it("persists canonical desired skills when creating an agent directly", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/agents") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + desiredSkills: ["paperclip"], + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); + expect(mockAgentService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + ); + }); + + it("includes canonical desired skills in hire approvals", async () => { + const db = createDb(true); + + const res = await request(createApp(db)) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + desiredSkills: ["paperclip"], + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); + expect(mockApprovalService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + payload: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + requestedConfigurationSnapshot: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + ); + }); }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts new file mode 100644 index 00000000..8ac0785d --- /dev/null +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -0,0 +1,113 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companySkillRoutes } from "../routes/company-skills.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockCompanySkillService = vi.hoisted(() => ({ + importFromSource: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companySkillService: () => mockCompanySkillService, + logActivity: mockLogActivity, +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", companySkillRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("company skill mutation permissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [], + warnings: [], + }); + mockLogActivity.mockResolvedValue(undefined); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.hasPermission.mockResolvedValue(false); + }); + + it("allows local board operators to mutate company skills", async () => { + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( + "company-1", + "https://github.com/vercel-labs/agent-browser", + ); + }); + + it("blocks same-company agents without management permission from mutating company skills", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: {}, + }); + + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); + }); + + it("allows agents with canCreateAgents to mutate company skills", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: { canCreateAgents: true }, + }); + + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( + "company-1", + "https://github.com/vercel-labs/agent-browser", + ); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3803f833..80bebc2d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -394,6 +394,39 @@ export function agentRoutes(db: Db) { }; } + async function resolveDesiredSkillAssignment( + companyId: string, + adapterType: string, + adapterConfig: Record, + requestedDesiredSkills: string[] | undefined, + ) { + if (!requestedDesiredSkills) { + return { + adapterConfig, + desiredSkills: null as string[] | null, + runtimeSkillEntries: null as Awaited> | null, + }; + } + + const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys( + companyId, + requestedDesiredSkills, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { + materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), + }); + const requiredSkills = runtimeSkillEntries + .filter((entry) => entry.required) + .map((entry) => entry.key); + const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills])); + + return { + adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills), + desiredSkills, + runtimeSkillEntries, + }; + } + function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { @@ -578,15 +611,19 @@ export function agentRoutes(db: Db) { .filter(Boolean), ), ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, { - materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(agent.adapterType), - }); - const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key); - const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills])); - const nextAdapterConfig = writePaperclipSkillSyncPreference( - agent.adapterConfig as Record, + const { + adapterConfig: nextAdapterConfig, desiredSkills, + runtimeSkillEntries, + } = await resolveDesiredSkillAssignment( + agent.companyId, + agent.adapterType, + agent.adapterConfig as Record, + requestedSkills, ); + if (!desiredSkills || !runtimeSkillEntries) { + throw unprocessable("Skill sync requires desiredSkills."); + } const actor = getActorInfo(req); const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig, @@ -955,14 +992,25 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId); const sourceIssueIds = parseSourceIssueIds(req.body); - const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body; + const { + desiredSkills: requestedDesiredSkills, + sourceIssueId: _sourceIssueId, + sourceIssueIds: _sourceIssueIds, + ...hireInput + } = req.body; const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), ); + const desiredSkillAssignment = await resolveDesiredSkillAssignment( + companyId, + hireInput.adapterType, + requestedAdapterConfig, + Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, + ); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, - requestedAdapterConfig, + desiredSkillAssignment.adapterConfig, { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( @@ -1030,6 +1078,7 @@ export function agentRoutes(db: Db) { typeof normalizedHireInput.budgetMonthlyCents === "number" ? normalizedHireInput.budgetMonthlyCents : agent.budgetMonthlyCents, + desiredSkills: desiredSkillAssignment.desiredSkills, metadata: requestedMetadata, agentId: agent.id, requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, @@ -1037,6 +1086,7 @@ export function agentRoutes(db: Db) { adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, + desiredSkills: desiredSkillAssignment.desiredSkills, }, }, decisionNote: null, @@ -1068,6 +1118,7 @@ export function agentRoutes(db: Db) { requiresApproval, approvalId: approval?.id ?? null, issueIds: sourceIssueIds, + desiredSkills: desiredSkillAssignment.desiredSkills, }, }); @@ -1096,23 +1147,33 @@ export function agentRoutes(db: Db) { assertBoard(req); } + const { + desiredSkills: requestedDesiredSkills, + ...createInput + } = req.body; const requestedAdapterConfig = applyCreateDefaultsByAdapterType( - req.body.adapterType, - ((req.body.adapterConfig ?? {}) as Record), + createInput.adapterType, + ((createInput.adapterConfig ?? {}) as Record), + ); + const desiredSkillAssignment = await resolveDesiredSkillAssignment( + companyId, + createInput.adapterType, + requestedAdapterConfig, + Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, - requestedAdapterConfig, + desiredSkillAssignment.adapterConfig, { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( companyId, - req.body.adapterType, + createInput.adapterType, normalizedAdapterConfig, ); const agent = await svc.create(companyId, { - ...req.body, + ...createInput, adapterConfig: normalizedAdapterConfig, status: "idle", spentMonthlyCents: 0, @@ -1129,7 +1190,11 @@ export function agentRoutes(db: Db) { action: "agent.created", entityType: "agent", entityId: agent.id, - details: { name: agent.name, role: agent.role }, + details: { + name: agent.name, + role: agent.role, + desiredSkills: desiredSkillAssignment.desiredSkills, + }, }); if (agent.budgetMonthlyCents > 0) { diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 5d9d1b94..c8de035b 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { companySkillCreateSchema, @@ -7,13 +7,50 @@ import { companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { companySkillService, logActivity } from "../services/index.js"; +import { accessService, agentService, companySkillService, logActivity } from "../services/index.js"; +import { forbidden } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; export function companySkillRoutes(db: Db) { const router = Router(); + const agents = agentService(db); + const access = accessService(db); const svc = companySkillService(db); + function canCreateAgents(agent: { permissions: Record | null | undefined }) { + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean((agent.permissions as Record).canCreateAgents); + } + + async function assertCanMutateCompanySkills(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + if (!allowed) { + throw forbidden("Missing permission: agents:create"); + } + return; + } + + if (!req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); + if (allowedByGrant || canCreateAgents(actorAgent)) { + return; + } + + throw forbidden("Missing permission: can create agents"); + } + router.get("/companies/:companyId/skills", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -63,7 +100,7 @@ export function companySkillRoutes(db: Db) { validate(companySkillCreateSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); + await assertCanMutateCompanySkills(req, companyId); const result = await svc.createLocalSkill(companyId, req.body); const actor = getActorInfo(req); @@ -92,7 +129,7 @@ export function companySkillRoutes(db: Db) { async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - assertCompanyAccess(req, companyId); + await assertCanMutateCompanySkills(req, companyId); const result = await svc.updateFile( companyId, skillId, @@ -125,7 +162,7 @@ export function companySkillRoutes(db: Db) { validate(companySkillImportSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); + await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); const result = await svc.importFromSource(companyId, source); @@ -156,7 +193,7 @@ export function companySkillRoutes(db: Db) { validate(companySkillProjectScanRequestSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); + await assertCanMutateCompanySkills(req, companyId); const result = await svc.scanProjectWorkspaces(companyId, req.body); const actor = getActorInfo(req); @@ -187,7 +224,7 @@ export function companySkillRoutes(db: Db) { router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; - assertCompanyAccess(req, companyId); + await assertCanMutateCompanySkills(req, companyId); const result = await svc.installUpdate(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index d528883c..2c43555e 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -1065,17 +1065,79 @@ function getSkillMeta(skill: CompanySkill): SkillSourceMeta { function resolveSkillReference( skills: CompanySkill[], reference: string, -): CompanySkill | null { - const normalizedReference = normalizeSkillKey(reference) ?? normalizeSkillSlug(reference); - if (!normalizedReference) return null; +): { skill: CompanySkill | null; ambiguous: boolean } { + const trimmed = reference.trim(); + if (!trimmed) { + return { skill: null, ambiguous: false }; + } - const byKey = skills.find((skill) => skill.key === normalizedReference); - if (byKey) return byKey; + const byId = skills.find((skill) => skill.id === trimmed); + if (byId) { + return { skill: byId, ambiguous: false }; + } - const bySlug = skills.filter((skill) => skill.slug === normalizedReference); - if (bySlug.length === 1) return bySlug[0] ?? null; + const normalizedKey = normalizeSkillKey(trimmed); + if (normalizedKey) { + const byKey = skills.find((skill) => skill.key === normalizedKey); + if (byKey) { + return { skill: byKey, ambiguous: false }; + } + } - return null; + const normalizedSlug = normalizeSkillSlug(trimmed); + if (!normalizedSlug) { + return { skill: null, ambiguous: false }; + } + + const bySlug = skills.filter((skill) => skill.slug === normalizedSlug); + if (bySlug.length === 1) { + return { skill: bySlug[0] ?? null, ambiguous: false }; + } + if (bySlug.length > 1) { + return { skill: null, ambiguous: true }; + } + + return { skill: null, ambiguous: false }; +} + +function resolveRequestedSkillKeysOrThrow( + skills: CompanySkill[], + requestedReferences: string[], +) { + const missing = new Set(); + const ambiguous = new Set(); + const resolved = new Set(); + + for (const reference of requestedReferences) { + const trimmed = reference.trim(); + if (!trimmed) continue; + + const match = resolveSkillReference(skills, trimmed); + if (match.skill) { + resolved.add(match.skill.key); + continue; + } + + if (match.ambiguous) { + ambiguous.add(trimmed); + continue; + } + + missing.add(trimmed); + } + + if (ambiguous.size > 0 || missing.size > 0) { + const problems: string[] = []; + if (ambiguous.size > 0) { + problems.push(`ambiguous references: ${Array.from(ambiguous).sort().join(", ")}`); + } + if (missing.size > 0) { + problems.push(`unknown references: ${Array.from(missing).sort().join(", ")}`); + } + throw unprocessable(`Invalid company skill selection (${problems.join("; ")}).`); + } + + return Array.from(resolved); } function resolveDesiredSkillKeys( @@ -1085,7 +1147,7 @@ function resolveDesiredSkillKeys( const preference = readPaperclipSkillSyncPreference(config); return Array.from(new Set( preference.desiredSkills - .map((reference) => resolveSkillReference(skills, reference)?.key ?? normalizeSkillKey(reference)) + .map((reference) => resolveSkillReference(skills, reference).skill?.key ?? normalizeSkillKey(reference)) .filter((value): value is string => Boolean(value)), )); } @@ -1952,6 +2014,10 @@ export function companySkillService(db: Db) { listFull, getById, getByKey, + resolveRequestedSkillKeys: async (companyId: string, requestedReferences: string[]) => { + const skills = await listFull(companyId); + return resolveRequestedSkillKeysOrThrow(skills, requestedReferences); + }, detail, updateStatus, readFile, diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index 7d4fe566..d4f73aea 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -61,6 +61,7 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ - icon (required in practice; use one from `/llms/agent-icons.txt`) - reporting line (`reportsTo`) - adapter type +- optional `desiredSkills` from the company skill library when this role needs installed skills on day one - adapter and runtime config aligned to this environment - capabilities - run prompt in adapter config (`promptTemplate` where applicable) @@ -79,6 +80,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h "icon": "crown", "reportsTo": "", "capabilities": "Owns technical roadmap, architecture, staffing, execution", + "desiredSkills": ["vercel-labs/agent-browser/agent-browser"], "adapterType": "codex_local", "adapterConfig": {"cwd": "/abs/path/to/repo", "model": "o4-mini"}, "runtimeConfig": {"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}}, @@ -128,6 +130,7 @@ For each linked issue, either: Before sending a hire request: +- if the role needs skills, make sure they already exist in the company library or install them first using the Paperclip company-skills workflow - Reuse proven config patterns from related agents where possible. - Set a concrete `icon` from `/llms/agent-icons.txt` so the new hire is identifiable in org and task views. - Avoid secrets in plain text unless required by adapter behavior. diff --git a/skills/paperclip-create-agent/references/api-reference.md b/skills/paperclip-create-agent/references/api-reference.md index 06c08c5b..baea6138 100644 --- a/skills/paperclip-create-agent/references/api-reference.md +++ b/skills/paperclip-create-agent/references/api-reference.md @@ -6,8 +6,12 @@ - `GET /llms/agent-configuration/:adapterType.txt` - `GET /llms/agent-icons.txt` - `GET /api/companies/:companyId/agent-configurations` +- `GET /api/companies/:companyId/skills` +- `POST /api/companies/:companyId/skills/import` - `GET /api/agents/:agentId/configuration` +- `POST /api/agents/:agentId/skills/sync` - `POST /api/companies/:companyId/agent-hires` +- `POST /api/companies/:companyId/agents` - `GET /api/agents/:agentId/config-revisions` - `POST /api/agents/:agentId/config-revisions/:revisionId/rollback` - `POST /api/issues/:issueId/approvals` @@ -34,6 +38,7 @@ Request body matches agent create shape: "icon": "crown", "reportsTo": "uuid-or-null", "capabilities": "Owns architecture and engineering execution", + "desiredSkills": ["vercel-labs/agent-browser/agent-browser"], "adapterType": "claude_local", "adapterConfig": { "cwd": "/absolute/path", @@ -64,13 +69,18 @@ Response: "approval": { "id": "uuid", "type": "hire_agent", - "status": "pending" + "status": "pending", + "payload": { + "desiredSkills": ["vercel-labs/agent-browser/agent-browser"] + } } } ``` If company setting disables required approval, `approval` is `null` and the agent is created as `idle`. +`desiredSkills` accepts company skill ids, canonical keys, or a unique slug. The server resolves and stores canonical company skill keys. + ## Approval Lifecycle Statuses: diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 797e91b5..4a0eaa34 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -124,6 +124,17 @@ Access control: 4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install). +## 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. + +Keep the detailed workflow out of this hot-path file. For concrete commands and examples, read: +`skills/paperclip/references/company-skills.md` + ## Critical Rules - **Always checkout** before working. Never PATCH to `in_progress` manually. @@ -254,6 +265,10 @@ PATCH /api/agents/{agentId}/instructions-path | Set instructions path | `PATCH /api/agents/:agentId/instructions-path` | | Release task | `POST /api/issues/:issueId/release` | | List agents | `GET /api/companies/:companyId/agents` | +| List company skills | `GET /api/companies/:companyId/skills` | +| Import company skills | `POST /api/companies/:companyId/skills/import` | +| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` | +| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` | | Dashboard | `GET /api/companies/:companyId/dashboard` | | Search issues | `GET /api/companies/:companyId/issues?q=search+term` | | Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` | diff --git a/skills/paperclip/references/company-skills.md b/skills/paperclip/references/company-skills.md new file mode 100644 index 00000000..839599be --- /dev/null +++ b/skills/paperclip/references/company-skills.md @@ -0,0 +1,151 @@ +# 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 + +- Company skill library: install, inspect, update, and read imported 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. install the skill into the company +2. assign the company skill to the agent +3. optionally do step 2 during hire/create with `desiredSkills` + +## Permission Model + +- Company skill reads: any same-company actor +- Company skill mutations: board, CEO, or an agent with the effective `agents:create` capability +- Agent skill assignment: same permission model as updating that agent + +## Core Endpoints + +- `GET /api/companies/:companyId/skills` +- `GET /api/companies/:companyId/skills/:skillId` +- `POST /api/companies/:companyId/skills/import` +- `POST /api/companies/:companyId/skills/scan-projects` +- `POST /api/companies/:companyId/skills/:skillId/install-update` +- `GET /api/agents/:agentId/skills` +- `POST /api/agents/:agentId/skills/sync` +- `POST /api/companies/:companyId/agent-hires` +- `POST /api/companies/:companyId/agents` + +## Install A Skill Into The Company + +Import from GitHub, a local path, or a `skills.sh`-style source 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": "https://github.com/vercel-labs/agent-browser" + }' +``` + +You can also use a source string such as: + +- `npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser` +- `vercel-labs/agent-browser/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. diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index 3eb793d6..db7e3674 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -25,6 +25,31 @@ function PayloadField({ label, value }: { label: string; value: unknown }) { ); } +function SkillList({ values }: { values: unknown }) { + if (!Array.isArray(values)) return null; + const items = values + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + if (items.length === 0) return null; + + return ( +
+ Skills +
+ {items.map((item) => ( + + {item} + + ))} +
+
+ ); +} + export function HireAgentPayload({ payload }: { payload: Record }) { return (
@@ -49,6 +74,7 @@ export function HireAgentPayload({ payload }: { payload: Record
)} + ); } diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 364e35a0..697bf649 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -4,9 +4,11 @@ import { useNavigate, useSearchParams } from "@/lib/router"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { agentsApi } from "../api/agents"; +import { companySkillsApi } from "../api/companySkills"; import { queryKeys } from "../lib/queryKeys"; import { AGENT_ROLES } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, @@ -68,6 +70,7 @@ export function NewAgent() { const [role, setRole] = useState("general"); const [reportsTo, setReportsTo] = useState(""); const [configValues, setConfigValues] = useState(defaultCreateValues); + const [selectedSkillKeys, setSelectedSkillKeys] = useState([]); const [roleOpen, setRoleOpen] = useState(false); const [reportsToOpen, setReportsToOpen] = useState(false); const [formError, setFormError] = useState(null); @@ -91,6 +94,12 @@ export function NewAgent() { enabled: Boolean(selectedCompanyId), }); + const { data: companySkills } = useQuery({ + queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""), + queryFn: () => companySkillsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const isFirstAgent = !agents || agents.length === 0; const effectiveRole = isFirstAgent ? "ceo" : role; @@ -174,6 +183,7 @@ export function NewAgent() { role: effectiveRole, ...(title.trim() ? { title: title.trim() } : {}), ...(reportsTo ? { reportsTo } : {}), + ...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}), adapterType: configValues.adapterType, adapterConfig: buildAdapterConfig(), runtimeConfig: { @@ -190,6 +200,16 @@ export function NewAgent() { } const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); + const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/")); + + function toggleSkill(key: string, checked: boolean) { + setSelectedSkillKeys((prev) => { + if (checked) { + return prev.includes(key) ? prev : [...prev, key]; + } + return prev.filter((value) => value !== key); + }); + } return (
@@ -311,6 +331,44 @@ export function NewAgent() { adapterModels={adapterModels} /> +
+
+
+

Company skills

+

+ Optional skills from the company library. Built-in Paperclip runtime skills are added automatically. +

+
+ {availableSkills.length === 0 ? ( +

+ No optional company skills installed yet. +

+ ) : ( +
+ {availableSkills.map((skill) => { + const inputId = `skill-${skill.id}`; + const checked = selectedSkillKeys.includes(skill.key); + return ( +
+ toggleSkill(skill.key, next === true)} + /> + +
+ ); + })} +
+ )} +
+
+ {/* Footer */}
{isFirstAgent && (