mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
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:
208
cli/src/commands/client/board.ts
Normal file
208
cli/src/commands/client/board.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
620
skills/paperclip-board/SKILL.md
Normal file
620
skills/paperclip-board/SKILL.md
Normal 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` |
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
315
ui/src/pages/BoardChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user