experiment: board concierge skill + web UI chat surface

Add a board-member skill that teaches Claude how to manage a Paperclip
company via chat — covering onboarding, hiring plans, approvals, task
monitoring, cost oversight, and agent system prompt management.

Phase 1 (Claude Code surface):
- Board skill at skills/paperclip-board/SKILL.md with full API reference
- CLI bootstrap command `paperclipai board setup` that installs the skill
  and prints env exports

Phase 2 (Web UI surface):
- New /board/chat/stream endpoint that spawns Claude with the board skill
  as system prompt, passing PAPERCLIP_API_URL and PAPERCLIP_COMPANY_ID
- BoardChat page with streaming responses, status indicators, and
  conversation persistence via Board Operations issue
- Sidebar nav link and route registration

The skill is a portable knowledge layer — same document powers Claude Code
(Surface 1), web UI chat (Surface 2), and future MCP server (Surface 3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
scotttong
2026-03-20 14:05:57 -07:00
parent 9bc683b17b
commit 08e0e91af0
8 changed files with 1386 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
import { Command } from "commander";
import {
removeMaintainerOnlySkillSymlinks,
resolvePaperclipSkillsDir,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
handleCommandError,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface BoardSetupOptions extends BaseClientOptions {
companyId?: string;
installSkills?: boolean;
}
interface SkillsInstallSummary {
tool: string;
target: string;
linked: string[];
removed: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function claudeSkillsHome(): string {
const fromEnv = process.env.CLAUDE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
return path.join(base, "skills");
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: string,
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
removed: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
summary.removed = await removeMaintainerOnlySkillSymlinks(
targetSkillsDir,
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
);
// Only install the board skill
const boardEntry = entries.find((e) => e.isDirectory() && e.name === "paperclip-board");
if (!boardEntry) {
summary.failed.push({ name: "paperclip-board", error: "Skill directory not found" });
return summary;
}
const source = path.join(sourceSkillsDir, boardEntry.name);
const target = path.join(targetSkillsDir, boardEntry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
if (existing.isSymbolicLink()) {
await fs.unlink(target);
} else {
summary.skipped.push(boardEntry.name);
return summary;
}
}
try {
await fs.symlink(source, target);
summary.linked.push(boardEntry.name);
} catch (err) {
summary.failed.push({
name: boardEntry.name,
error: err instanceof Error ? err.message : String(err),
});
}
return summary;
}
function buildBoardEnvExports(input: { apiBase: string; companyId?: string }): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
const lines = [`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`];
if (input.companyId) {
lines.push(`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`);
}
return lines.join("\n");
}
export function registerBoardCommands(program: Command): void {
const board = program.command("board").description("Board member operations");
addCommonClientOptions(
board
.command("setup")
.description(
"Install the board-member skill for Claude Code and print shell exports for managing your Paperclip company",
)
.option("-C, --company-id <id>", "Company ID (if you already have one)")
.option("--no-install-skills", "Skip installing the board skill into ~/.claude/skills")
.action(async (opts: BoardSetupOptions) => {
try {
const ctx = resolveCommandContext(opts);
// Attempt to auto-detect company if not provided
let companyId = opts.companyId?.trim() || ctx.companyId;
if (!companyId) {
try {
const companies = await ctx.api.get<Array<{ id: string; name: string }>>(
"/api/companies",
);
if (companies && companies.length === 1) {
companyId = companies[0].id;
console.log(`Auto-detected company: ${companies[0].name} (${companyId})`);
} else if (companies && companies.length > 1) {
console.log(
"Multiple companies found. Pass --company-id or set PAPERCLIP_COMPANY_ID:",
);
for (const c of companies) {
console.log(` ${c.id} ${c.name}`);
}
}
} catch {
// Server might not be running yet — that's OK
}
}
// Install skills
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [
path.resolve(process.cwd(), "skills"),
]);
if (!skillsDir) {
console.log(
"Warning: Could not locate skills directory. Skipping skill installation.",
);
} else {
installSummaries.push(
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
);
}
}
const exportsText = buildBoardEnvExports({
apiBase: ctx.api.apiBase,
companyId,
});
if (ctx.json) {
const output = {
companyId,
skills: installSummaries,
exports: exportsText,
};
console.log(JSON.stringify(output, null, 2));
return;
}
// Print summary
console.log("");
console.log("Board setup complete!");
console.log("");
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
if (summary.linked.length > 0) {
console.log(
`Skill installed: ${summary.linked.join(", ")}${summary.target}`,
);
}
for (const failed of summary.failed) {
console.log(` Failed: ${failed.name}: ${failed.error}`);
}
}
console.log("");
}
console.log("# Run this in your shell before launching Claude Code:");
console.log(exportsText);
console.log("");
console.log("# Then start Claude Code:");
console.log("claude");
if (!companyId) {
console.log("");
console.log(
"Note: No company detected. Claude Code will guide you through creating one.",
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}

View File

@@ -19,6 +19,7 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.
import { loadPaperclipEnvFile } from "./config/env.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerBoardCommands } from "./commands/client/board.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -138,6 +139,7 @@ registerActivityCommands(program);
registerDashboardCommands(program);
registerWorktreeCommands(program);
registerPluginCommands(program);
registerBoardCommands(program);
const auth = program.command("auth").description("Authentication and bootstrap utilities");

View File

@@ -2,6 +2,8 @@ import { Router } from "express";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, heartbeatRuns, issueWorkProducts } from "@paperclipai/db";
import { eq } from "drizzle-orm";
@@ -616,5 +618,237 @@ If nothing to create, output empty arrays. ALWAYS include this signal line.`;
proc.stdin.end();
});
// ── Board Concierge Chat ──────────────────────────────────────────────
// Same streaming pattern as /agents/:id/chat/stream but uses the
// board-member skill as the system prompt instead of the CEO agent's
// prompt. Allows the board to manage their company from the web UI chat.
let _boardSkillCache: string | null = null;
function loadBoardSkill(): string {
if (_boardSkillCache) return _boardSkillCache;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const skillPath = path.resolve(__dirname, "../../../skills/paperclip-board/SKILL.md");
try {
let content = fs.readFileSync(skillPath, "utf-8");
// Strip YAML frontmatter
content = content.replace(/^---[\s\S]*?---\s*\n/, "");
_boardSkillCache = content;
return content;
} catch {
return "You are a board-level assistant helping a human manage their AI-agent company through Paperclip. Help them create companies, hire agents, approve tasks, and monitor their organization.";
}
}
router.post("/board/chat/stream", async (req, res) => {
const { companyId, message, taskId } = req.body as {
companyId: string;
message: string;
taskId?: string;
};
if (!companyId || !message) {
res.status(400).json({ error: "companyId and message are required" });
return;
}
const issueSvc = issueService(db);
let issueId = taskId;
// Find or create the Board Operations issue
if (!issueId) {
const companyIssues = await issueSvc.list(companyId, {
q: "Board Operations",
});
const boardIssue = companyIssues.find(
(i: any) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
);
if (boardIssue) {
issueId = boardIssue.id;
} else {
const created = await issueSvc.create(companyId, {
title: "Board Operations",
description: "Standing issue for board concierge conversations and decision log",
status: "in_progress",
priority: "medium",
});
issueId = created.id;
}
}
const resolvedIssueId = issueId!;
// Save user message as comment
await issueSvc.addComment(resolvedIssueId, message, {
userId: (req as any).actor?.userId ?? "local-board",
});
// Build conversation history from recent comments
const comments = await issueSvc.listComments(resolvedIssueId);
const sorted = [...comments].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
const recent = sorted.slice(-20);
const history = recent
.map((c) => {
const role = c.authorAgentId ? "ASSISTANT" : "USER";
return `${role}: ${c.body}`;
})
.join("\n\n");
// Load board skill as system prompt
const systemPrompt = loadBoardSkill();
// Compose prompt with conversation history
const prompt = history
? `Here is the conversation so far:\n\n${history}\n\nRespond to the latest message from the user.`
: message;
// Set up SSE
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders();
res.write(`data: ${JSON.stringify({ type: "start", issueId: resolvedIssueId })}\n\n`);
// Spawn claude CLI with board skill
const args = [
"-p",
"-",
"--output-format",
"stream-json",
"--verbose",
"--append-system-prompt",
systemPrompt,
"--model",
"sonnet",
"--dangerously-skip-permissions",
];
// Determine the API URL for the spawned process
const serverAddr = (req as any).socket?.localAddress ?? "127.0.0.1";
const serverPort = (req as any).socket?.localPort ?? 3000;
const apiUrl = `http://${serverAddr === "::" || serverAddr === "::1" ? "127.0.0.1" : serverAddr}:${serverPort}`;
const proc = spawn("claude", args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: "/tmp",
env: {
...process.env,
PAPERCLIP_API_URL: apiUrl,
PAPERCLIP_COMPANY_ID: companyId,
},
});
let fullResponse = "";
const startTime = Date.now();
let killed = false;
// 120s timeout (board conversations can involve multiple API calls)
const timeout = setTimeout(() => {
killed = true;
proc.kill("SIGTERM");
}, 120000);
// Stream stdout — parse stream-json events
let stdoutBuf = "";
proc.stdout.on("data", (data: Buffer) => {
stdoutBuf += data.toString();
const lines = stdoutBuf.split("\n");
stdoutBuf = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "assistant" && event.message?.content) {
for (const block of event.message.content) {
if (block.type === "text" && block.text && res.writable) {
fullResponse += block.text;
res.write(`data: ${JSON.stringify({ type: "chunk", text: block.text })}\n\n`);
}
}
} else if (event.type === "content_block_delta" && event.delta?.text) {
fullResponse += event.delta.text;
if (res.writable) {
res.write(`data: ${JSON.stringify({ type: "chunk", text: event.delta.text })}\n\n`);
}
} else if (event.type === "content_block_start" && event.content_block?.type === "tool_use" && res.writable) {
// Forward tool-use status so UI can show activity
const toolName = event.content_block.name ?? "working";
let statusText = "Working...";
if (toolName === "Bash" || toolName === "bash") {
statusText = "Running a command...";
} else if (toolName === "Read" || toolName === "read") {
statusText = "Reading a file...";
} else if (toolName === "Grep" || toolName === "grep") {
statusText = "Searching...";
} else {
statusText = `Using ${toolName}...`;
}
res.write(`data: ${JSON.stringify({ type: "status", text: statusText })}\n\n`);
} else if (event.type === "result" && event.result && !fullResponse) {
fullResponse = event.result;
if (res.writable) {
res.write(`data: ${JSON.stringify({ type: "chunk", text: event.result })}\n\n`);
}
}
} catch {
// Not JSON or unknown format — skip
}
}
});
proc.stderr.on("data", (data: Buffer) => {
console.error("[board/chat/stream stderr]", data.toString());
});
proc.on("close", async (exitCode) => {
clearTimeout(timeout);
// Save response as a comment (strip any action signals)
const cleanedResponse = stripActionSignals(fullResponse).trim();
if (cleanedResponse) {
try {
// Save as a system/board comment (no agentId)
await issueSvc.addComment(resolvedIssueId, cleanedResponse, {
userId: "board-concierge",
});
} catch {
/* best effort */
}
}
const duration = Date.now() - startTime;
if (res.writable) {
res.write(
`data: ${JSON.stringify({
type: "done",
issueId: resolvedIssueId,
duration,
exitCode: exitCode ?? 0,
timedOut: killed,
})}\n\n`,
);
}
if (res.writable) res.end();
});
proc.on("error", (err) => {
clearTimeout(timeout);
if (res.writable) {
res.write(`data: ${JSON.stringify({ type: "error", message: err.message })}\n\n`);
res.end();
}
});
// Pipe the prompt to stdin
proc.stdin.write(prompt);
proc.stdin.end();
});
return router;
}

View File

@@ -0,0 +1,620 @@
---
name: paperclip-board
description: >
Manage a Paperclip company as a board member via chat. Covers onboarding
(company creation, CEO setup, hiring plans), agent management, approvals,
task monitoring, cost oversight, and work product review. Use this skill
whenever the user wants to interact with their Paperclip control plane.
---
# Paperclip Board Skill
You are a board-level assistant helping a human manage their AI-agent company through Paperclip. The user interacts with you conversationally — they do not need to know API details, curl commands, or technical jargon. Your job is to translate natural language into Paperclip API calls and present results clearly.
## Authentication & Environment
**Environment variables** (set by `paperclipai board setup`):
- `PAPERCLIP_API_URL` — base URL of the Paperclip server (e.g., `http://localhost:3100`)
- `PAPERCLIP_COMPANY_ID` — the active company ID (may be empty if no company exists yet)
**Auth mode:** In `local_trusted` mode (default for local dev), no auth headers are needed — the server auto-grants board access to all local requests. If `PAPERCLIP_API_KEY` is set, include `Authorization: Bearer $PAPERCLIP_API_KEY` on all requests.
**Making API calls:** Use `curl -sS` via bash. All endpoints are under `/api`. All request/response bodies are JSON. Always use `Content-Type: application/json` on POST/PATCH/PUT requests.
**Critical rules:**
- Always re-read a document or config from the API before modifying it (write-path freshness)
- Never hard-code the API URL — always use `$PAPERCLIP_API_URL`
- Always include web UI links in responses: `$PAPERCLIP_API_URL/{companyPrefix}/...`
- Present results conversationally — summarize, don't dump JSON
## Session Startup
Every time you begin a new conversation with the user:
1. Check if `PAPERCLIP_API_URL` is set. If not, tell the user to run `pnpm paperclipai board setup`.
2. Check if `PAPERCLIP_COMPANY_ID` is set.
- If set: fetch the dashboard to understand current state.
- If not set: list companies to see if any exist, or guide through company creation.
3. Check if a decision log exists: `GET $PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?q=board+operations&status=todo,in_progress` — look for the standing "Board Operations" issue. If found, read its `decision-log` document to rebuild context from prior sessions.
4. Greet the user with a brief status summary.
```bash
# Fetch dashboard
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/dashboard"
```
Present the dashboard as:
```
{Company Name} Dashboard
────────────────────────
Agents: {active} active, {paused} paused
Tasks: {open} open ({inProgress} in progress, {blocked} blocked)
Budget: ${monthSpendCents/100} / ${monthBudgetCents/100} this month ({utilization}%)
Pending approvals: {pendingApprovals}
{If pendingApprovals > 0: list them briefly}
{If blocked > 0: mention blocked tasks}
```
## Onboarding Flow
Guide the user through these steps when they're setting up for the first time.
### Step 1: Create or Select a Company
```bash
# List existing companies
curl -sS "$PAPERCLIP_API_URL/api/companies"
# Create a new company
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies" \
-H "Content-Type: application/json" \
-d '{
"name": "Company Name",
"description": "Company mission / description",
"budgetMonthlyCents": 50000
}'
```
Ask the user for:
- Company name
- Mission / description (store in `description` field)
- Monthly budget (suggest a reasonable default like $500 = 50000 cents)
The response includes the company `id` and auto-generated `issuePrefix`. Tell the user both.
After creating, set `PAPERCLIP_COMPANY_ID` for subsequent calls. Also set `requireBoardApprovalForNewAgents: true` so all hires go through governance:
```bash
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/companies/{companyId}" \
-H "Content-Type: application/json" \
-d '{"requireBoardApprovalForNewAgents": true}'
```
### Step 2: Create the CEO Agent
The CEO is the first agent. Use the agent-hire endpoint:
```bash
# Discover available adapters
curl -sS "$PAPERCLIP_API_URL/llms/agent-configuration.txt"
# Read adapter-specific docs (e.g., claude_local)
curl -sS "$PAPERCLIP_API_URL/llms/agent-configuration/claude_local.txt"
# Discover available icons
curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt"
# Submit hire request
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
-H "Content-Type: application/json" \
-d '{
"name": "CEO Name",
"role": "ceo",
"title": "Chief Executive Officer",
"icon": "crown",
"capabilities": "Strategic planning, team management, task delegation",
"adapterType": "claude_local",
"adapterConfig": {
"cwd": "/path/to/working/directory",
"model": "sonnet"
},
"runtimeConfig": {
"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}
},
"permissions": {"canCreateAgents": true},
"budgetMonthlyCents": 10000
}'
```
Guide the user through:
- CEO name and icon (show available icons)
- Working directory (where the CEO will operate)
- Adapter type (default: `claude_local`)
- Budget
Generate the CEO's system prompt using the Agent System Prompt Template (Section D below).
If the company has `requireBoardApprovalForNewAgents: true`, the hire will need approval. Check if an approval was created and auto-approve it for the CEO (since the user just asked to create it):
```bash
# Check pending approvals
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/approvals?status=pending"
# Approve the CEO hire
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{approvalId}/approve" \
-H "Content-Type: application/json" \
-d '{"decisionNote": "CEO hire approved by board during onboarding"}'
```
### Step 3: Create the Board Operations Issue
Create a standing issue for decision logging and board operations:
```bash
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
-H "Content-Type: application/json" \
-d '{
"title": "Board Operations",
"description": "Standing issue for board decision log and operations tracking",
"status": "in_progress",
"priority": "medium"
}'
```
Then create the decision log document:
```bash
curl -sS -X PUT "$PAPERCLIP_API_URL/api/issues/{boardIssueId}/documents/decision-log" \
-H "Content-Type: application/json" \
-d '{
"title": "Decision Log",
"format": "markdown",
"body": "# Decision Log — {Company Name}\n\n## {today date}\n- Created company {name} with mission: {description}\n- Hired CEO agent \"{ceo name}\"\n"
}'
```
Also write this to a local file at `./artifacts/decision-log.md` so the user can view it directly.
### Step 4: Launch the Company
Start the CEO's first heartbeat:
```bash
curl -sS -X POST "$PAPERCLIP_API_URL/api/agents/{ceoId}/heartbeat/invoke" \
-H "Content-Type: application/json"
```
## Hiring Plan Loop
When the user wants to build a hiring plan:
1. **Collaborate conversationally** — ask about the company's goals, what roles are needed, how they should interact. Use your judgment to suggest roles.
2. **Store as a document artifact** — create an issue for the hiring plan, then attach the plan as a document:
```bash
# Create the hiring plan issue
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
-H "Content-Type: application/json" \
-d '{
"title": "Hiring Plan",
"description": "Develop and execute the team hiring plan",
"status": "in_progress",
"priority": "high"
}'
# Attach the plan document
curl -sS -X PUT "$PAPERCLIP_API_URL/api/issues/{issueId}/documents/hiring-plan" \
-H "Content-Type: application/json" \
-d '{
"title": "Hiring Plan",
"format": "markdown",
"body": "# Hiring Plan\n\n## Roles\n\n### 1. Role Name\n- Focus: ...\n- Reports to: ...\n- Budget: ...\n"
}'
```
3. **Also write a local file** at `./artifacts/hiring-plan.md` so the user can open and edit it directly.
4. **Iterate** — when the user suggests changes:
- In chat: update both the API document and local file
- If user says they edited the file: re-read `./artifacts/hiring-plan.md` and sync to API
- If user says they edited in web UI: re-fetch from API with `GET /api/issues/{id}/documents/hiring-plan`
5. **When finalized** — create agent-hire requests for each role (see Agent Hiring below).
## Agent System Prompt Template
Every new agent's system prompt MUST include these sections by default (unless the board explicitly overrides):
```markdown
# {Agent Name}
## Description
{One-line role summary}
## Expertise
{Core expertise — what this agent knows, how it thinks, what it does}
## Priorities
{Ordered list of what matters most for this agent's work}
## Boundaries
{What this agent should NOT do, scope limits, guardrails}
## Tool Permissions
{Which tools/APIs this agent can use, and any exclusions}
## Communication Guidelines
{How this agent reports status, asks for help, formats output}
## Collaboration & Escalation
{Which agents this one works with, when to escalate, to whom}
```
Present each agent's draft system prompt to the user for review before submitting the hire.
## Agent Hiring
For each agent to hire:
```bash
# Compare existing agent configurations
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configurations"
# Submit hire request
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
-H "Content-Type: application/json" \
-d '{
"name": "Agent Name",
"role": "general",
"title": "Role Title",
"icon": "icon-name",
"reportsTo": "{ceo-or-manager-agent-id}",
"capabilities": "What this agent can do",
"adapterType": "claude_local",
"adapterConfig": {
"cwd": "/path/to/working/directory",
"model": "sonnet",
"systemPrompt": "... the full system prompt from the template ..."
},
"runtimeConfig": {
"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}
},
"budgetMonthlyCents": 5000
}'
```
### Cross-Agent Escalation Path Updates
When a new agent is hired, update existing agents' Collaboration & Escalation sections:
1. **Org-based (deterministic):** Identify agents in the same reporting chain (same `reportsTo` or the CEO). These always need to know about the new hire.
2. **Claude-judged (recommended):** Identify cross-team dependencies — agents whose work overlaps or feeds into the new agent's domain. Include your reasoning.
3. **Present all proposed changes for board approval** — distinguish the two categories:
```
Hiring @designer — proposed escalation path updates:
Org-based (same reporting chain):
@ceo — add: "@designer handles brand assets, visual design, UX research.
Route design reviews through @designer."
@frontend-engineer — add: "Escalate visual design decisions to @designer.
Request mockups before building new UI components."
Additionally recommended:
@content-strategist — add: "Request visual assets (headers, social images)
from @designer. Coordinate brand voice with design."
Reason: Content pipeline will need visual assets for blog posts and social.
Approve these updates? (approve all / review individually / edit)
```
4. Only after board approval, update each affected agent:
```bash
# Fetch current config first (write-path freshness)
curl -sS "$PAPERCLIP_API_URL/api/agents/{agentId}"
# Update the agent's config with new escalation paths
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/agents/{agentId}" \
-H "Content-Type: application/json" \
-d '{
"adapterConfig": { ... updated config with new Collaboration section ... }
}'
```
5. Log the changes and reasoning in the decision log.
## Approvals
```bash
# List pending approvals
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/approvals?status=pending"
# Approve
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{id}/approve" \
-H "Content-Type: application/json" \
-d '{"decisionNote": "Approved by board"}'
# Reject
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{id}/reject" \
-H "Content-Type: application/json" \
-d '{"decisionNote": "Reason for rejection"}'
# Request revision
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{id}/request-revision" \
-H "Content-Type: application/json" \
-d '{"decisionNote": "Please adjust X, Y, Z"}'
```
Present approvals as:
```
Pending Approvals
─────────────────
1. [hire] Designer — submitted by @ceo
View: {baseUrl}/{prefix}/approvals/{id}
→ approve / reject / request revision
2. [tool] Icon library ($12/mo) — requested by @designer
→ approve / reject
```
For batch approval: list all pending, let the user approve all or review individually.
## Task Management
```bash
# List open tasks
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?status=todo,in_progress,blocked"
# Get task detail
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}"
# Get task comments
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/comments"
# Create a task
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
-H "Content-Type: application/json" \
-d '{
"title": "Task title",
"description": "What needs to be done",
"status": "todo",
"priority": "medium",
"assigneeAgentId": "{agent-id}",
"projectId": "{project-id}",
"parentId": "{parent-issue-id}"
}'
# Update a task
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/issues/{issueId}" \
-H "Content-Type: application/json" \
-d '{"status": "done", "comment": "Completed"}'
# Add a comment
curl -sS -X POST "$PAPERCLIP_API_URL/api/issues/{issueId}/comments" \
-H "Content-Type: application/json" \
-d '{"body": "Comment text in markdown"}'
# Search issues
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?q=search+term"
```
Present tasks as:
```
{PREFIX}-{number}: {title} [{status}] → @{assignee}
Priority: {priority}
Latest: "{last comment snippet...}"
View: {baseUrl}/{prefix}/issues/{identifier}
```
## Agent Monitoring
```bash
# List all agents
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agents"
# Get agent detail
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}"
# Get agent config revisions (change history)
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}/config-revisions"
```
Present agents as:
```
Team Overview
─────────────
@ceo (Atlas) — active, last heartbeat 5m ago
Budget: $45 / $100 (45%)
Working on: PAP-12 Homepage redesign
@frontend-engineer — active, last heartbeat 2m ago
Budget: $30 / $50 (60%)
Working on: PAP-15 Blog template
```
## Cost Monitoring
```bash
# Overall summary
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/summary"
# Breakdown by agent
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/by-agent"
# Breakdown by project
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/by-project"
# Optional date range
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/summary?from=2026-03-01&to=2026-03-31"
```
Present costs as:
```
Costs This Month
────────────────
Total: $145.23 / $500.00 (29%)
By Agent:
@ceo $45.12 (31%)
@frontend-eng $62.30 (43%)
@content-strat $37.81 (26%)
```
## Work Products
```bash
# List work products for an issue
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/work-products"
# View a document
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/documents/{key}"
# View document revisions
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/documents/{key}/revisions"
```
Present work products with status and links:
```
Work Products — PAP-12
──────────────────────
1. Homepage mockup [ready_for_review] — artifact
View: {baseUrl}/{prefix}/issues/PAP-12#document-mockup
2. Feature branch [active] — branch
URL: https://github.com/...
```
## Editing Agent System Prompts
Three ways the user can edit system prompts:
**In chat:** User describes changes, you update via API:
```bash
# Always re-fetch before modifying
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}"
# Then update
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/agents/{id}" \
-H "Content-Type: application/json" \
-d '{"adapterConfig": { ... updated config ... }}'
```
**Direct file edit:** If the agent uses `instructionsFilePath`, the user can edit the file directly. When they tell you they're done, re-read the file and confirm changes.
**Web UI edit:** User edits at `{baseUrl}/{prefix}/agents/{agentUrlKey}`. When they say "sync up," re-fetch from the API.
**Viewing change history:**
```bash
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}/config-revisions"
```
Present as a changelog:
```
Config History — @designer
──────────────────────────
Rev 3 (2026-03-21 14:30) — changed: systemPrompt
Added UX research to expertise section
Rev 2 (2026-03-21 10:15) — changed: budgetMonthlyCents
Budget increased from $50 to $100
Rev 1 (2026-03-20 16:00) — initial configuration
```
## Decision Log
Maintain a decision log for session continuity. Log major decisions — not every interaction.
**What to log:**
- Company creation and configuration changes
- Agents hired, modified, or removed
- Budget changes
- Strategic decisions (what was prioritized, what was cut and why)
- Approvals granted or rejected with reasoning
**When to log:**
- After completing a significant action (hiring, approving, budget change)
- At the end of a session if notable decisions were made
**How to log:**
1. Update the API document:
```bash
# Fetch current log
curl -sS "$PAPERCLIP_API_URL/api/issues/{boardIssueId}/documents/decision-log"
# Update with new entries appended
curl -sS -X PUT "$PAPERCLIP_API_URL/api/issues/{boardIssueId}/documents/decision-log" \
-H "Content-Type: application/json" \
-d '{
"title": "Decision Log",
"format": "markdown",
"body": "... existing content ... \n\n## {date}\n- New decision\n",
"baseRevisionId": "{current revision id}"
}'
```
2. Also update the local file at `./artifacts/decision-log.md`.
## Presentation Rules
- Use markdown tables for lists (agents, tasks, costs)
- Use bold for status values: **in_progress**, **blocked**, **completed**
- Always include web UI links: `View: {PAPERCLIP_API_URL}/{prefix}/issues/{identifier}`
- For org charts: generate mermaid diagrams or ASCII art
- Smart summaries: surface what needs attention first, then the rest
- Task format: `PAP-123: Build landing page [in_progress] → @engineer`
- Keep responses concise — the user can ask to drill deeper
- When presenting multiple items for action (approvals, hires), number them for easy reference
- Derive the company's URL prefix from any issue identifier (e.g., `PAP-315` → prefix is `PAP`)
## Link Format
All web UI links must include the company prefix:
- Issues: `/{prefix}/issues/{identifier}` (e.g., `/PAP/issues/PAP-12`)
- Agents: `/{prefix}/agents/{agent-url-key}`
- Approvals: `/{prefix}/approvals/{approval-id}`
- Projects: `/{prefix}/projects/{project-url-key}`
- Documents: `/{prefix}/issues/{identifier}#document-{key}`
## Key Endpoints Reference
| Action | Method | Endpoint |
|--------|--------|----------|
| List companies | GET | `/api/companies` |
| Create company | POST | `/api/companies` |
| Update company | PATCH | `/api/companies/:id` |
| Get company | GET | `/api/companies/:id` |
| Dashboard | GET | `/api/companies/:companyId/dashboard` |
| List agents | GET | `/api/companies/:companyId/agents` |
| Get agent | GET | `/api/agents/:id` |
| Update agent | PATCH | `/api/agents/:id` |
| Agent configs | GET | `/api/companies/:companyId/agent-configurations` |
| Config revisions | GET | `/api/agents/:id/config-revisions` |
| Hire agent | POST | `/api/companies/:companyId/agent-hires` |
| Invoke heartbeat | POST | `/api/agents/:id/heartbeat/invoke` |
| List issues | GET | `/api/companies/:companyId/issues` |
| Create issue | POST | `/api/companies/:companyId/issues` |
| Get issue | GET | `/api/issues/:id` |
| Update issue | PATCH | `/api/issues/:id` |
| Issue comments | GET | `/api/issues/:id/comments` |
| Add comment | POST | `/api/issues/:id/comments` |
| Issue documents | GET | `/api/issues/:id/documents` |
| Get document | GET | `/api/issues/:id/documents/:key` |
| Create/update doc | PUT | `/api/issues/:id/documents/:key` |
| Work products | GET | `/api/issues/:id/work-products` |
| List approvals | GET | `/api/companies/:companyId/approvals` |
| Approve | POST | `/api/approvals/:id/approve` |
| Reject | POST | `/api/approvals/:id/reject` |
| Request revision | POST | `/api/approvals/:id/request-revision` |
| Cost summary | GET | `/api/companies/:companyId/costs/summary` |
| Costs by agent | GET | `/api/companies/:companyId/costs/by-agent` |
| Costs by project | GET | `/api/companies/:companyId/costs/by-project` |
| Adapter docs | GET | `/llms/agent-configuration.txt` |
| Adapter detail | GET | `/llms/agent-configuration/:adapterType.txt` |
| Agent icons | GET | `/llms/agent-icons.txt` |
| Set instructions | PATCH | `/api/agents/:id/instructions-path` |
| Search issues | GET | `/api/companies/:companyId/issues?q=term` |

View File

@@ -8,6 +8,7 @@ import { authApi } from "./api/auth";
import { healthApi } from "./api/health";
import { Artifacts } from "./pages/Artifacts";
import { Chat } from "./pages/Chat";
import { BoardChat } from "./pages/BoardChat";
import { Dashboard } from "./pages/Dashboard";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
@@ -116,6 +117,7 @@ function boardRoutes() {
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="chat" element={<Chat />} />
<Route path="board-chat" element={<BoardChat />} />
<Route path="artifacts" element={<Artifacts />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />

View File

@@ -84,6 +84,7 @@ export function Sidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/chat" label="Chat" icon={MessageSquare} />
<SidebarNavItem to="/board-chat" label="Board Chat" icon={MessageSquare} />
{/* New Task button aligned with nav items */}
<button
onClick={() => openNewIssue()}

View File

@@ -13,6 +13,10 @@ const BOARD_ROUTE_ROOTS = new Set([
"activity",
"inbox",
"design-guide",
"chat",
"board-chat",
"artifacts",
"onboarding",
]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);

315
ui/src/pages/BoardChat.tsx Normal file
View File

@@ -0,0 +1,315 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { MarkdownBody } from "../components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { Loader2, Send } from "lucide-react";
import { cn } from "../lib/utils";
/**
* Board Concierge Chat — a chat interface powered by the board-member skill.
* Uses /board/chat/stream to invoke Claude with the board skill as system prompt.
* The user manages their Paperclip company through natural conversation.
*/
export function BoardChat() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
useEffect(() => {
setBreadcrumbs([{ label: "Board Chat" }]);
}, [setBreadcrumbs]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [statusText, setStatusText] = useState("");
const [boardIssueId, setBoardIssueId] = useState<string | null>(null);
const [elapsedSec, setElapsedSec] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Find or detect the board operations issue
const { data: issues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
useEffect(() => {
if (!issues) return;
const boardIssue = issues.find(
(i) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
);
if (boardIssue) {
setBoardIssueId(boardIssue.id);
}
}, [issues]);
// Fetch comments for the board issue
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(boardIssueId ?? ""),
queryFn: () => issuesApi.listComments(boardIssueId!),
enabled: !!boardIssueId,
refetchInterval: 3000,
});
const sortedComments = (comments ?? [])
.slice()
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [sortedComments.length, streamingText, statusText]);
// Elapsed timer for thinking state
useEffect(() => {
if (sending && !streamingText) {
setElapsedSec(0);
elapsedTimerRef.current = setInterval(() => {
setElapsedSec((prev) => prev + 1);
}, 1000);
} else {
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current);
elapsedTimerRef.current = null;
}
}
return () => {
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
};
}, [sending, streamingText]);
const sendMessage = useCallback(
async (body: string) => {
const trimmed = body.trim();
if (!trimmed || sending || !selectedCompanyId) return;
setSending(true);
setInput("");
setStreamingText("");
setStatusText("Connecting...");
try {
const controller = new AbortController();
const fetchTimeout = setTimeout(() => controller.abort(), 130000);
const res = await fetch("/api/board/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyId: selectedCompanyId,
message: trimmed,
taskId: boardIssueId ?? undefined,
}),
signal: controller.signal,
});
clearTimeout(fetchTimeout);
if (!res.ok || !res.body) {
throw new Error("Board chat stream not available");
}
setStatusText("Thinking...");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6));
if (event.type === "chunk" && event.text) {
accumulated += event.text;
setStreamingText(accumulated);
setStatusText("");
} else if (event.type === "status" && event.text) {
setStatusText(event.text);
} else if (event.type === "start" && event.issueId) {
setBoardIssueId(event.issueId);
} else if (event.type === "done") {
if (event.issueId) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.comments(event.issueId),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(selectedCompanyId),
});
}
}
} catch {
/* malformed SSE line */
}
}
}
setStreamingText("");
setStatusText("");
if (boardIssueId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
}
} catch (err) {
console.error("Board chat error:", err);
setStatusText("");
} finally {
setSending(false);
inputRef.current?.focus();
}
},
[sending, selectedCompanyId, boardIssueId, queryClient],
);
const handleSend = useCallback(() => {
sendMessage(input);
}, [input, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
if (!selectedCompanyId) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-sm">
<h2 className="text-lg font-semibold">No company selected</h2>
<p className="text-sm text-muted-foreground mt-2">
Select a company to start chatting with your board concierge.
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100%+3rem)] -m-6">
{/* Header */}
<div className="shrink-0 border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Board Concierge</h2>
<p className="text-xs text-muted-foreground">
{selectedCompany?.name ?? "Your company"} manage your org through chat
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{sortedComments.length === 0 && !streamingText && !sending && (
<div className="text-center py-12">
<p className="text-sm text-muted-foreground mb-4">
Ask me anything about your company hiring, tasks, costs, approvals.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{[
"What's happening today?",
"Help me build a hiring plan",
"Show me my costs",
"List pending approvals",
].map((suggestion) => (
<button
key={suggestion}
onClick={() => sendMessage(suggestion)}
className="px-3 py-1.5 text-xs rounded-full border border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{sortedComments.map((comment) => {
const isUser = !comment.authorAgentId && comment.authorUserId !== "board-concierge";
return (
<div
key={comment.id}
className={cn("flex", isUser ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] px-3 py-2 text-sm",
isUser
? "bg-blue-600 text-white [border-radius:12px_12px_0px_12px]"
: "bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
)}
>
<MarkdownBody>{comment.body ?? ""}</MarkdownBody>
</div>
</div>
);
})}
{/* Streaming response */}
{streamingText && (
<div className="flex justify-start">
<div className="max-w-[85%] [border-radius:12px_12px_12px_0px] px-3 py-2 text-sm bg-muted text-foreground">
<MarkdownBody>{streamingText}</MarkdownBody>
</div>
</div>
)}
{/* Status / thinking indicator */}
{sending && !streamingText && (
<div className="flex justify-start">
<div className="rounded-lg px-3 py-2 text-sm bg-muted text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
<span>{statusText || "Thinking..."}</span>
{elapsedSec > 0 && (
<span className="text-xs opacity-60">{elapsedSec}s</span>
)}
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="shrink-0 border-t border-border p-3">
<div className="flex gap-2 items-end">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything about your company..."
rows={1}
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
disabled={sending}
/>
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim() || sending}
className="shrink-0"
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}