From 1c2873d22a5efc44942de85b84893da94a4ccbf6 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Wed, 25 Feb 2026 08:39:11 -0600 Subject: [PATCH] feat: enforce agent icon enum and expose via LLM endpoint Move icon name list to shared constants with strict enum validation. Add /llms/agent-icons.txt endpoint, pass icon through hire flow, and update skills to reference icon discovery step. Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/constants.ts | 45 +++++++++++++++++++ packages/shared/src/validators/agent.ts | 3 +- server/src/routes/agents.ts | 1 + server/src/routes/llms.ts | 19 ++++++++ skills/paperclip-create-agent/SKILL.md | 16 +++++-- .../references/api-reference.md | 2 + skills/paperclip/SKILL.md | 2 +- skills/paperclip/references/api-reference.md | 20 ++++++++- ui/src/components/AgentIconPicker.tsx | 15 ++++--- ui/src/components/ApprovalPayload.tsx | 1 + 10 files changed, 113 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 11082792..f0ecfcb2 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -39,6 +39,51 @@ export const AGENT_ROLES = [ ] as const; export type AgentRole = (typeof AGENT_ROLES)[number]; +export const AGENT_ICON_NAMES = [ + "bot", + "cpu", + "brain", + "zap", + "rocket", + "code", + "terminal", + "shield", + "eye", + "search", + "wrench", + "hammer", + "lightbulb", + "sparkles", + "star", + "heart", + "flame", + "bug", + "cog", + "database", + "globe", + "lock", + "mail", + "message-square", + "file-code", + "git-branch", + "package", + "puzzle", + "target", + "wand", + "atom", + "circuit-board", + "radar", + "swords", + "telescope", + "microscope", + "crown", + "gem", + "hexagon", + "pentagon", + "fingerprint", +] as const; +export type AgentIconName = (typeof AGENT_ICON_NAMES)[number]; + export const ISSUE_STATUSES = [ "backlog", "todo", diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 74918685..800d5eef 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { AGENT_ADAPTER_TYPES, + AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, } from "../constants.js"; @@ -27,7 +28,7 @@ export const createAgentSchema = z.object({ name: z.string().min(1), role: z.enum(AGENT_ROLES).optional().default("general"), title: z.string().optional().nullable(), - icon: z.string().optional().nullable(), + icon: z.enum(AGENT_ICON_NAMES).optional().nullable(), reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"), diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a610c7ca..5f5f4ffe 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -512,6 +512,7 @@ export function agentRoutes(db: Db) { name: normalizedHireInput.name, role: normalizedHireInput.role, title: normalizedHireInput.title ?? null, + icon: normalizedHireInput.icon ?? null, reportsTo: normalizedHireInput.reportsTo ?? null, capabilities: normalizedHireInput.capabilities ?? null, adapterType: requestedAdapterType, diff --git a/server/src/routes/llms.ts b/server/src/routes/llms.ts index 06b398ef..c4215940 100644 --- a/server/src/routes/llms.ts +++ b/server/src/routes/llms.ts @@ -1,5 +1,6 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclip/db"; +import { AGENT_ICON_NAMES } from "@paperclip/shared"; import { forbidden } from "../errors.js"; import { listServerAdapters } from "../adapters/index.js"; import { agentService } from "../services/agents.js"; @@ -38,6 +39,9 @@ export function llmRoutes(db: Db) { "- GET /api/agents/:id/configuration", "- POST /api/companies/:companyId/agent-hires", "", + "Agent identity references:", + "- GET /llms/agent-icons.txt", + "", "Notes:", "- Sensitive values are redacted in configuration read APIs.", "- New hires may be created in pending_approval state depending on company settings.", @@ -46,6 +50,21 @@ export function llmRoutes(db: Db) { res.type("text/plain").send(lines.join("\n")); }); + router.get("/llms/agent-icons.txt", async (req, res) => { + await assertCanRead(req); + const lines = [ + "# Paperclip Agent Icon Names", + "", + "Set the `icon` field on hire/create payloads to one of:", + ...AGENT_ICON_NAMES.map((name) => `- ${name}`), + "", + "Example:", + '{ "name": "SearchOps", "role": "researcher", "icon": "search" }', + "", + ]; + res.type("text/plain").send(lines.join("\n")); + }); + router.get("/llms/agent-configuration/:adapterType.txt", async (req, res) => { await assertCanRead(req); const adapterType = req.params.adapterType as string; diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index d103be81..f9d56c88 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -49,8 +49,16 @@ curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configura -H "Authorization: Bearer $PAPERCLIP_API_KEY" ``` -5. Draft the new hire config: +5. Discover allowed agent icons and pick one that matches the role. + +```sh +curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" +``` + +6. Draft the new hire config: - role/title/name +- icon (required in practice; use one from `/llms/agent-icons.txt`) - reporting line (`reportsTo`) - adapter type - adapter and runtime config aligned to this environment @@ -58,7 +66,7 @@ curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configura - initial prompt in adapter config (`bootstrapPromptTemplate`/`promptTemplate` where applicable) - source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue -6. Submit hire request. +7. Submit hire request. ```sh curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \ @@ -68,6 +76,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h "name": "CTO", "role": "cto", "title": "Chief Technology Officer", + "icon": "crown", "reportsTo": "", "capabilities": "Owns technical roadmap, architecture, staffing, execution", "adapterType": "codex_local", @@ -77,7 +86,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h }' ``` -7. Handle governance state: +8. Handle governance state: - if response has `approval`, hire is `pending_approval` - monitor and discuss on approval thread - when the board approves, you will be woken with `PAPERCLIP_APPROVAL_ID`; read linked issues and close/comment follow-up @@ -120,6 +129,7 @@ For each linked issue, either: Before sending a hire request: - 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. - Ensure reporting line is correct and in-company. - Ensure prompt is role-specific and operationally scoped. diff --git a/skills/paperclip-create-agent/references/api-reference.md b/skills/paperclip-create-agent/references/api-reference.md index f6f1a355..06c08c5b 100644 --- a/skills/paperclip-create-agent/references/api-reference.md +++ b/skills/paperclip-create-agent/references/api-reference.md @@ -4,6 +4,7 @@ - `GET /llms/agent-configuration.txt` - `GET /llms/agent-configuration/:adapterType.txt` +- `GET /llms/agent-icons.txt` - `GET /api/companies/:companyId/agent-configurations` - `GET /api/agents/:agentId/configuration` - `POST /api/companies/:companyId/agent-hires` @@ -30,6 +31,7 @@ Request body matches agent create shape: "name": "CTO", "role": "cto", "title": "Chief Technology Officer", + "icon": "crown", "reportsTo": "uuid-or-null", "capabilities": "Owns architecture and engineering execution", "adapterType": "claude_local", diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 898dd175..a0aaad66 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -53,7 +53,7 @@ Headers: Authorization: Bearer $PAPERCLIP_API_KEY, X-Paperclip-Run-Id: $PAPERCLI 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.** `GET /api/issues/{issueId}` (includes `ancestors` array — parent chain to root). `GET /api/issues/{issueId}/comments`. Read ancestors to understand _why_ this task exists. +**Step 6 — Understand context.** `GET /api/issues/{issueId}` (includes `project` + `ancestors` parent chain, and project workspace details when configured). `GET /api/issues/{issueId}/comments`. Read ancestors to understand _why_ this task exists. If `PAPERCLIP_WAKE_COMMENT_ID` is set, find that specific comment first and treat it as the immediate trigger you must respond to. Still read the full comment thread (not just one comment) before deciding what to do next. **Step 7 — Do the work.** Use your tools and capabilities. diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index da522393..44f6ada0 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -55,7 +55,25 @@ Includes the issue's `project` and `goal` (with descriptions), plus each ancesto "name": "Auth System", "description": "End-to-end authentication and authorization", "status": "active", - "goalId": "goal-1" + "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": [ diff --git a/ui/src/components/AgentIconPicker.tsx b/ui/src/components/AgentIconPicker.tsx index f40abbff..9ab18c14 100644 --- a/ui/src/components/AgentIconPicker.tsx +++ b/ui/src/components/AgentIconPicker.tsx @@ -43,6 +43,7 @@ import { Fingerprint, type LucideIcon, } from "lucide-react"; +import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclip/shared"; import { Popover, PopoverContent, @@ -51,7 +52,7 @@ import { import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; -export const AGENT_ICONS: Record = { +export const AGENT_ICONS: Record = { bot: Bot, cpu: Cpu, brain: Brain, @@ -95,10 +96,13 @@ export const AGENT_ICONS: Record = { fingerprint: Fingerprint, }; -const DEFAULT_ICON = "bot"; +const DEFAULT_ICON: AgentIconName = "bot"; export function getAgentIcon(iconName: string | null | undefined): LucideIcon { - return AGENT_ICONS[iconName ?? DEFAULT_ICON] ?? AGENT_ICONS[DEFAULT_ICON]; + if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) { + return AGENT_ICONS[iconName as AgentIconName]; + } + return AGENT_ICONS[DEFAULT_ICON]; } interface AgentIconProps { @@ -122,9 +126,10 @@ export function AgentIconPicker({ value, onChange, children }: AgentIconPickerPr const [search, setSearch] = useState(""); const filtered = useMemo(() => { - if (!search) return Object.entries(AGENT_ICONS); + const entries = AGENT_ICON_NAMES.map((name) => [name, AGENT_ICONS[name]] as const); + if (!search) return entries; const q = search.toLowerCase(); - return Object.entries(AGENT_ICONS).filter(([name]) => name.includes(q)); + return entries.filter(([name]) => name.includes(q)); }, [search]); return ( diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index d2e10e6a..ac7149f8 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -31,6 +31,7 @@ export function HireAgentPayload({ payload }: { payload: Record + {!!payload.capabilities && (
Capabilities