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 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 08:39:11 -06:00
parent 9f049aa4f3
commit 1c2873d22a
10 changed files with 113 additions and 11 deletions

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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": "<ceo-agent-id>",
"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.

View File

@@ -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",

View File

@@ -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.

View File

@@ -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": [

View File

@@ -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<string, LucideIcon> = {
export const AGENT_ICONS: Record<AgentIconName, LucideIcon> = {
bot: Bot,
cpu: Cpu,
brain: Brain,
@@ -95,10 +96,13 @@ export const AGENT_ICONS: Record<string, LucideIcon> = {
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 (

View File

@@ -31,6 +31,7 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
</div>
<PayloadField label="Role" value={payload.role} />
<PayloadField label="Title" value={payload.title} />
<PayloadField label="Icon" value={payload.icon} />
{!!payload.capabilities && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs pt-0.5">Capabilities</span>