Files
copilotkit__copilotkit/showcase/scripts/create-integration/index.ts
2026-04-09 08:33:41 -07:00

1581 lines
48 KiB
TypeScript

/*
* Integration Package Generator
*
* Scaffolds a new integration package in showcase/packages/<slug>/
* with all required files, demo stubs, and deployment configs.
*
* Usage:
* npx tsx create-integration/index.ts \
* --name "Anthropic (Claude Agent SDK)" \
* --slug anthropic-claude-sdk \
* --category provider-sdk \
* --language python \
* --features agentic-chat,hitl,tool-rendering
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import yaml from "yaml";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, "..", "..");
const PACKAGES_DIR = path.join(ROOT, "packages");
const FEATURE_REGISTRY_PATH = path.join(
ROOT,
"shared",
"feature-registry.json",
);
interface Feature {
id: string;
name: string;
category: string;
description: string;
}
interface CLIArgs {
name: string;
slug: string;
category: string;
language: string;
features: string[];
extraDeps: string[];
}
function parseArgs(): CLIArgs {
const args = process.argv.slice(2);
const parsed: Record<string, string> = {};
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith("--")) {
const key = args[i].slice(2);
const val = args[i + 1];
if (val && !val.startsWith("--")) {
parsed[key] = val;
i++;
}
}
}
if (
!parsed.name ||
!parsed.slug ||
!parsed.category ||
!parsed.language ||
!parsed.features
) {
console.error(
"Usage: create-integration --name <name> --slug <slug> --category <category> --language <language> --features <comma-separated> [--deps <comma-separated>]",
);
console.error("\nRequired flags:");
console.error(
" --name Display name (e.g. 'Anthropic (Claude Agent SDK)')",
);
console.error(" --slug URL-safe ID (e.g. 'anthropic-claude-sdk')");
console.error(
" --category One of: popular, agent-framework, enterprise-platform, provider-sdk, protocol, emerging, starter",
);
console.error(" --language One of: python, typescript, dotnet");
console.error(
" --features Comma-separated feature IDs (e.g. 'agentic-chat,hitl,tool-rendering')",
);
console.error("\nOptional flags:");
console.error(
" --deps Extra npm dependencies (e.g. '@ag-ui/mastra,@mastra/core')",
);
process.exit(1);
}
return {
name: parsed.name,
slug: parsed.slug,
category: parsed.category,
language: parsed.language,
features: parsed.features.split(",").map((f) => f.trim()),
extraDeps: parsed.deps ? parsed.deps.split(",").map((d) => d.trim()) : [],
};
}
function loadFeatureRegistry(): Feature[] {
const raw = fs.readFileSync(FEATURE_REGISTRY_PATH, "utf-8");
return JSON.parse(raw).features;
}
function writeFile(filePath: string, content: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
console.log(` Created: ${path.relative(ROOT, filePath)}`);
}
function generateManifest(args: CLIArgs, features: Feature[]): string {
const demos = args.features.map((featureId) => {
const feature = features.find((f) => f.id === featureId);
const name = feature?.name || featureId;
const description = feature?.description || "";
const tags = [feature?.category || "general"].filter(Boolean);
return {
id: featureId,
name,
description,
tags,
route: `/demos/${featureId}`,
};
});
const manifest = {
name: args.name,
slug: args.slug,
category: args.category,
language: args.language,
logo: `/logos/${args.slug}.svg`,
description: `CopilotKit integration with ${args.name}`,
partner_docs: null,
repo: `https://github.com/CopilotKit/CopilotKit/tree/main/showcase/packages/${args.slug}`,
copilotkit_version: "2.0.0",
backend_url: `https://showcase-${args.slug}-production.up.railway.app`,
deployed: false,
generative_ui: ["constrained-explicit"],
interaction_modalities: ["chat"],
features: args.features,
demos,
managed_platform: undefined as { name: string; url: string } | undefined,
};
const MANAGED_PLATFORMS: Record<string, { name: string; url: string }> = {
LangGraph: { name: "LangGraph Platform", url: "https://langsmith.com" },
Mastra: { name: "Mastra Cloud", url: "https://mastra.ai/cloud" },
CrewAI: { name: "CrewAI Enterprise", url: "https://crewai.com/amp" },
Agno: { name: "Agent OS", url: "https://os.agno.com" },
AG2: { name: "Agent OS", url: "https://ag2.ai/product" },
Strands: {
name: "AWS Bedrock AgentCore",
url: "https://aws.amazon.com/bedrock/agents/",
},
};
for (const [prefix, platform] of Object.entries(MANAGED_PLATFORMS)) {
if (args.name.startsWith(prefix)) {
manifest.managed_platform = platform;
break;
}
}
return yaml.stringify(manifest);
}
function generatePackageJson(args: CLIArgs): string {
const devCmd =
args.language === "typescript"
? '"dev": "next dev --turbopack"'
: '"dev": "concurrently \\"next dev --turbopack\\" \\"python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\\""';
return (
JSON.stringify(
{
name: `@copilotkit/showcase-${args.slug}`,
version: "0.1.0",
private: true,
scripts: {
dev:
args.language === "typescript"
? "next dev --turbopack"
: 'concurrently "next dev --turbopack" "python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload"',
build: "next build",
start: "next start",
lint: "next lint",
"test:e2e": "playwright test",
},
dependencies: {
"@ag-ui/client": "^0.0.43",
"@copilotkit/react-core": "next",
"@copilotkit/runtime": "next",
next: "^15.0.0",
react: "^19.0.0",
"react-dom": "^19.0.0",
zod: "^3.24.0",
...Object.fromEntries(args.extraDeps.map((d) => [d, "latest"])),
},
devDependencies: {
"@playwright/test": "^1.50.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
typescript: "^5.7.0",
tailwindcss: "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
postcss: "^8.5.0",
...(args.language !== "typescript" ? { concurrently: "^9.1.0" } : {}),
},
},
null,
2,
) + "\n"
);
}
function generateLayout(): string {
return `import type { Metadata } from "next";
import "@copilotkit/react-core/v2/styles.css";
import "./globals.css";
export const metadata: Metadata = {
title: "CopilotKit Showcase",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<script
dangerouslySetInnerHTML={{
__html: \\\`
console.log('[showcase] Demo loaded:', window.location.href);
console.log('[showcase] In iframe:', window.self !== window.top);
window.addEventListener('error', function(e) {
console.error('[showcase] Uncaught error:', e.message, e.filename, e.lineno);
});
window.addEventListener('unhandledrejection', function(e) {
console.error('[showcase] Unhandled rejection:', e.reason);
});
\\\`,
}}
/>
{children}
</body>
</html>
);
}
`;
}
function generatePostcssConfig(): string {
return `/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
`;
}
function generateGlobalsCss(): string {
return `@import "tailwindcss";
@import "@copilotkit/react-core/v2/styles.css";
:root {
--copilot-kit-background-color: #f8f9fa;
--copilot-kit-primary-color: #0066ff;
}
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
}
`;
}
function generateIndexPage(args: CLIArgs, features: Feature[]): string {
const demoLinks = args.features
.map((featureId) => {
const feature = features.find((f) => f.id === featureId);
const name = feature?.name || featureId;
const desc = feature?.description || "";
return ` <a key="${featureId}" href="/demos/${featureId}" className="demo-card">
<h3>${name}</h3>
<p>${desc}</p>
</a>`;
})
.join("\n");
return `export default function Home() {
return (
<main style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}>
<h1>${args.name}</h1>
<p>Integration ID: ${args.slug}</p>
<h2 style={{ marginTop: "2rem" }}>Demos</h2>
<div style={{ display: "grid", gap: "1rem", marginTop: "1rem" }}>
${demoLinks}
</div>
</main>
);
}
`;
}
function generateDemoPage(
featureId: string,
feature: Feature | undefined,
args: CLIArgs,
): string {
return `"use client";
import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import {
CopilotChat,
useFrontendTool,
useRenderTool,
useAgentContext,
useConfigureSuggestions,
useHumanInTheLoop,
useInterrupt,
} from "@copilotkit/react-core/v2";
import { z } from "zod";
import { DemoErrorBoundary } from "../error-boundary";
export default function ${toPascalCase(featureId)}Demo() {
return (
<DemoErrorBoundary demoName="${feature?.name || featureId}">
<CopilotKit runtimeUrl="/api/copilotkit" agent="${featureId}">
<DemoContent />
</CopilotKit>
</DemoErrorBoundary>
);
}
function DemoContent() {
// TODO: Implement ${feature?.name || featureId} demo
// See the LangGraph Python reference implementation for patterns
//
// IMPORTANT: Use inline styles for any UI rendered inside the chat
// (useRenderTool, useHumanInTheLoop callbacks). Tailwind classes get
// purged by Tailwind v4 in this context. See STYLING-GUIDE.md.
//
// Key hooks available:
// useFrontendTool({ name, description, parameters: z.object({...}), handler })
// useRenderTool({ name: "tool_name", render: ({ args }) => <Component /> })
// useHumanInTheLoop({ name, description, parameters, handler: ({ args, respond }) => ... })
// useAgentContext({ description, value })
// useConfigureSuggestions({ suggestions: [{ title, message }] })
// useInterrupt({ render: ({ event, resolve }) => <Component /> })
useConfigureSuggestions({
suggestions: [
{ title: "Get started", message: "Hello! What can you do?" },
],
});
return (
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full md:w-4/5 md:h-4/5 rounded-lg">
<CopilotChat
className="h-full rounded-2xl max-w-6xl mx-auto"
/>
</div>
</div>
);
}
`;
}
function getDemoInteraction(featureId: string): string {
switch (featureId) {
case "tool-rendering":
return `- "What's the weather like in San Francisco?"
- "Check the weather in Tokyo and New York"
- "Can you look up the current conditions in London?"`;
case "hitl":
return `- "Change the background color to a warm sunset gradient"
- "Set the theme to dark mode"
- "Make the background a calming blue-green gradient"
When the agent proposes an action, you'll see an approval prompt. Click **Approve** to let it proceed or **Reject** to cancel.`;
case "gen-ui-tool-based":
return `- "What's the weather forecast for this week in San Francisco?"
- "Show me the weather in Paris"
- "Compare the weather in Tokyo and London"
The agent generates structured data via tools, and the frontend renders it as rich UI components.`;
default:
return `- "TODO: Add example prompts"
- "TODO: Add more examples"`;
}
}
function getDemoTechnicalDetails(featureId: string): string {
switch (featureId) {
case "tool-rendering":
return `- **Backend tools** are defined in the agent (e.g., \\\`get_weather\\\`) and called by the LLM when the user's query matches
- **\\\`useRenderTool\\\`** on the frontend registers a React component that renders whenever the agent calls that tool
- The render function receives \\\`args\\\` (input parameters), \\\`result\\\` (tool output), and \\\`status\\\` ("executing" or "complete") so the UI can show loading states
- The tool result is displayed as a rich UI card instead of plain text — demonstrating how agent actions can produce structured, visual output`;
case "hitl":
return `- **Human-in-the-Loop (HITL)** lets the agent propose actions that require user approval before execution
- The agent calls a tool (like \\\`change_background\\\`), and CopilotKit intercepts it to show a confirmation dialog
- \\\`useHumanInTheLoop\\\` registers a frontend tool with \\\`requireConfirmation: true\\\`, adding the approval step
- The user sees what the agent wants to do (with the proposed arguments) and can approve or reject
- This pattern is essential for high-stakes actions — database writes, API calls, or any irreversible operation`;
case "gen-ui-tool-based":
return `- **Generative UI** means the agent's tool calls produce structured data that the frontend renders as custom React components
- Unlike plain text responses, the agent returns tool results with typed parameters (city, temperature, conditions)
- \\\`useRenderTool\\\` maps each tool name to a React component, so \\\`get_weather\\\` renders a weather card with icons, temperature displays, and forecast details
- The agent decides when to call the tool based on context — it can mix tool-based UI generation with regular text responses
- This pattern enables agents to create dynamic, data-driven interfaces on demand`;
default:
return `- TODO: Describe the technical implementation
- TODO: Explain the hooks and components used
- TODO: Note any framework-specific patterns`;
}
}
function generateDemoReadme(
featureId: string,
feature: Feature | undefined,
): string {
return `# ${feature?.name || featureId}
## What This Demo Shows
${feature?.description || "TODO: Add description"}
## How to Interact
Try asking your Copilot to:
${getDemoInteraction(featureId)}
## Technical Details
What's happening technically:
${getDemoTechnicalDetails(featureId)}
## Building With This
If you're extending this demo or building something similar, here are key things to know:
### Styling Inside the Chat
Content rendered inside CopilotKit's chat area (via \\\`useRenderTool\\\`, \\\`useHumanInTheLoop\\\`, \\\`useFrontendTool\\\`) runs inside CopilotKit's component tree. Standard Tailwind classes may not work here because Tailwind v4 can't statically detect them.
**Use inline styles** for any UI rendered inside the chat:
\\\`\\\`\\\`tsx
// Do this
<div style={{ padding: "24px", borderRadius: "12px", background: "#fff" }}>
// Not this — Tailwind may purge these classes
<div className="p-6 rounded-xl bg-white">
\\\`\\\`\\\`
### Chat Layout
Wrap \\\`CopilotChat\\\` in a constraining div for proper spacing:
\\\`\\\`\\\`tsx
<div className="flex justify-center items-center h-screen w-full">
<div className="h-full w-full md:w-4/5 md:h-4/5 rounded-lg">
<CopilotChat className="h-full rounded-2xl max-w-6xl mx-auto" />
</div>
</div>
\\\`\\\`\\\`
### Overriding CopilotKit Styles
CopilotKit uses \\\`cpk:\\\` prefixed classes internally. To override them, create a **separate CSS file** (not in globals.css — Tailwind purges it):
\\\`\\\`\\\`css
/* copilotkit-overrides.css */
.copilotKitInput {
border-radius: 0.75rem;
border: 1px solid var(--copilot-kit-separator-color) !important;
}
\\\`\\\`\\\`
Import it in \\\`layout.tsx\\\` after \\\`globals.css\\\`.
### Images and Icons
- Don't reference local image files from agent-generated content (they won't exist). Add \\\`onError\\\` fallbacks.
- Use emoji instead of SVG icons inside chat messages (\\\`fill="currentColor"\\\` renders unpredictably in the chat context).
See the full [Styling Guide](https://github.com/CopilotKit/CopilotKit/blob/main/showcase/STYLING-GUIDE.md) for more details.
`;
}
function generateRuntimeRoute(args: CLIArgs): string {
if (args.language === "typescript") {
return `import { NextRequest } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
// TODO: Import the appropriate agent adapter for ${args.name}
// Examples:
// import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
// import { MastraAgent } from "@ag-ui/mastra";
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// TODO: Configure agents for ${args.name}
// agents: { default: new YourAgent({ ... }) },
}),
});
return handleRequest(req);
};
`;
}
return `import { NextRequest } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// TODO: Configure the agent adapter for ${args.name}
// The adapter should point to AGENT_URL
}),
});
return handleRequest(req);
};
`;
}
function generateHealthRoute(args: CLIArgs): string {
const isLangGraph = args.name.startsWith("LangGraph");
const isInProcess = args.language === "typescript" && !isLangGraph;
const agentUrl = isInProcess
? "N/A (in-process)"
: isLangGraph
? "http://localhost:8123"
: "http://localhost:8000";
// LangGraph Platform exposes /ok; our backends expose /health
const probePath = isLangGraph ? "/ok" : "/health";
return `import { NextRequest, NextResponse } from "next/server";
const AGENT_URL = process.env.AGENT_URL || process.env.LANGGRAPH_DEPLOYMENT_URL || "${agentUrl}";
export async function GET(req: NextRequest) {
// Check agent backend reachability
let agentStatus = "unknown";
try {
const res = await fetch(\`\${AGENT_URL}${probePath}\`, { signal: AbortSignal.timeout(3000) });
agentStatus = res.ok ? "ok" : "error";
} catch {
agentStatus = "down";
}
// Public response: safe to expose
const publicResponse: Record<string, any> = {
status: "ok",
integration: "${args.slug}",
agent: agentStatus,
timestamp: new Date().toISOString(),
};
// Extended diagnostics: only with debug token
const token = req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("debug");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (token && expectedToken && token === expectedToken) {
publicResponse.diagnostics = {
agent_url: AGENT_URL,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
},
};
}
const httpStatus = agentStatus === "ok" || agentStatus === "in-process" ? 200 : 503;
return NextResponse.json(publicResponse, { status: httpStatus });
}
`;
}
function generateDebugRoute(args: CLIArgs): string {
const isLangGraph = args.name.startsWith("LangGraph");
// LangGraph Platform exposes /ok; our backends expose /health
const probePath = isLangGraph ? "/ok" : "/health";
return `import { NextRequest, NextResponse } from "next/server";
// Request log (in-memory ring buffer, last 50 requests)
const requestLog: Array<{ time: string; method: string; path: string; status: number; durationMs: number }> = [];
const MAX_LOG_SIZE = 50;
export function logRequest(method: string, path: string, status: number, durationMs: number) {
requestLog.push({ time: new Date().toISOString(), method, path, status, durationMs });
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
}
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token = req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || process.env.LANGGRAPH_DEPLOYMENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(\`\${AGENT_URL}${probePath}\`, { signal: AbortSignal.timeout(3000) });
agentStatus = res.ok ? "ok" : "error";
agentDetail = \`HTTP \${res.status}\`;
} catch (e: any) {
agentStatus = "down";
agentDetail = e.message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "${args.slug}",
uptime: \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: \`\${Math.round(mem.rss / 1024 / 1024)}MB\`,
heapUsed: \`\${Math.round(mem.heapUsed / 1024 / 1024)}MB\`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
recentRequests: requestLog.slice(-20),
nodeVersion: process.version,
});
}
`;
}
function generateErrorBoundary(): string {
return `"use client";
import React from "react";
interface ErrorBoundaryState { hasError: boolean; error: Error | null; }
export class DemoErrorBoundary extends React.Component<
{ children: React.ReactNode; demoName: string },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode; demoName: string }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(\`[DemoErrorBoundary] \${this.props.demoName} crashed:\`, error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center", height: "100vh", padding: "2rem",
fontFamily: "system-ui, sans-serif", color: "#888", textAlign: "center",
}}>
<div style={{ fontSize: "48px", marginBottom: "16px" }}>Warning</div>
<h2 style={{ fontSize: "18px", fontWeight: 600, color: "#ccc", marginBottom: "8px" }}>
{this.props.demoName} — Demo Error
</h2>
<p style={{ fontSize: "14px", maxWidth: "400px", lineHeight: 1.5 }}>
The demo encountered an error. This usually means the agent backend is not responding.
Check the server logs and /api/health endpoint.
</p>
<pre style={{
marginTop: "16px", padding: "12px 16px", background: "#1a1a2e",
borderRadius: "8px", fontSize: "12px", color: "#f87171",
maxWidth: "500px", overflow: "auto", textAlign: "left",
}}>
{this.state.error?.message}
</pre>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
marginTop: "16px", padding: "8px 20px", background: "#333",
border: "1px solid #555", borderRadius: "8px", color: "#ccc",
cursor: "pointer", fontSize: "13px",
}}
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
`;
}
function generateDockerfile(args: CLIArgs): string {
if (args.language === "typescript") {
return `FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
EXPOSE 10000
ENV NODE_ENV=production
CMD ["npm", "start"]
`;
}
return `# Stage 1: Build Next.js frontend
FROM node:20-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production image with Node.js + Python
FROM python:3.12-slim AS runner
RUN apt-get update && apt-get install -y --no-install-recommends \\
curl && \\
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \\
apt-get install -y nodejs && \\
npm install -g corepack && \\
corepack enable && \\
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Python dependencies
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Next.js build artifacts
COPY --from=frontend /app/.next ./.next
COPY --from=frontend /app/node_modules ./node_modules
COPY --from=frontend /app/package.json ./
COPY --from=frontend /app/public ./public
# Agent code
COPY src/agent_server.py ./
COPY src/app/demos/*/agent.py ./agents/
# Entrypoint
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 10000
ENV NODE_ENV=production
CMD ["./entrypoint.sh"]
`;
}
function generateEntrypoint(args: CLIArgs): string {
if (args.language === "typescript") {
return `#!/bin/bash
exec npx next start --port \${PORT:-10000}
`;
}
return `#!/bin/bash
set -e
# Start agent backend
python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &
# Start Next.js frontend
npx next start --port \${PORT:-10000} &
# Wait for either process to exit
wait -n
exit $?
`;
}
function generateSmokeRoute(args: CLIArgs): string {
return `import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "${args.slug}";
export const dynamic = "force-dynamic";
export const maxDuration = 30;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
|| \\\`http://localhost:\${process.env.PORT || 10000}\\\`;
try {
const res = await fetch(\\\`\${baseUrl}/api/copilotkit\\\`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: \\\`smoke-\${Date.now()}\\\`,
runId: \\\`smoke-run-\${Date.now()}\\\`,
state: {},
messages: [
{
id: \\\`smoke-msg-\${Date.now()}\\\`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(25000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json({
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: \\\`Runtime returned \${res.status}: \${errBody.slice(0, 200)}\\\`,
latency_ms: latency,
timestamp: new Date().toISOString(),
}, { status: 502 });
}
// Response is SSE stream — just verify we got content
const body = await res.text();
if (body.length === 0) {
return NextResponse.json({
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
}, { status: 502 });
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout")) stage = "timeout";
else if (err.message.includes("fetch") || err.message.includes("ECONNREFUSED")) stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json({
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
}, { status: 502 });
}
}
`;
}
function generateEnvExample(args: CLIArgs): string {
const lines = [
"# API Keys (shared across integrations)",
"OPENAI_API_KEY=sk-...",
"ANTHROPIC_API_KEY=sk-ant-...",
"",
];
if (args.language !== "typescript") {
lines.push("# Agent backend URL (for the CopilotKit runtime proxy)");
lines.push("AGENT_URL=http://localhost:8000");
lines.push("");
}
lines.push("# Showcase");
lines.push("NEXT_PUBLIC_BASE_URL=http://localhost:3000");
return lines.join("\n") + "\n";
}
function generateAgentServer(args: CLIArgs): string {
if (args.language === "typescript") {
return "";
}
return `"""
Agent Server for ${args.name}
FastAPI server that hosts the agent backend.
The Next.js CopilotKit runtime proxies requests here.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="${args.name} Agent Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "ok"}
# TODO: Add CopilotKit agent endpoint
# See the LangGraph Python reference implementation for patterns
# @app.post("/copilotkit")
# async def copilotkit(request: Request):
# ...
`;
}
function generateRequirementsTxt(args: CLIArgs): string {
if (args.language === "typescript") {
return "";
}
return `fastapi>=0.115.0
uvicorn>=0.34.0
copilotkit>=0.1.0
# TODO: Add framework-specific dependencies
`;
}
function generateE2ETest(
featureId: string,
feature: Feature | undefined,
): string {
return `import { test, expect } from "@playwright/test";
test.describe("${feature?.name || featureId}", () => {
test("page loads and chat renders", async ({ page }) => {
await page.goto("/demos/${featureId}");
// Chat interface should be visible
await expect(page.getByPlaceholder("Type a message")).toBeVisible();
});
test("can send a message and receive a response", async ({ page }) => {
await page.goto("/demos/${featureId}");
const input = page.getByPlaceholder("Type a message");
await input.fill("Hello");
await input.press("Enter");
// Wait for agent response (adjust timeout as needed)
await expect(page.locator('[data-role="assistant"]').first()).toBeVisible({
timeout: 30000,
});
});
// TODO: Add feature-specific assertions
});
`;
}
function generateQATemplate(
featureId: string,
feature: Feature | undefined,
args: CLIArgs,
): string {
return `# QA: ${feature?.name || featureId}${args.name}
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the demo page
- [ ] Verify the chat interface loads
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
- [ ] TODO: Add checks specific to ${feature?.name || featureId}
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
`;
}
function generatePlaywrightConfig(): string {
return `import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
},
});
`;
}
function generateNextConfig(): string {
return `import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow iframe embedding from the showcase shell
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors *;",
},
],
},
];
},
};
export default nextConfig;
`;
}
function generateTsConfig(): string {
return (
JSON.stringify(
{
compilerOptions: {
target: "ES2017",
lib: ["dom", "dom.iterable", "esnext"],
allowJs: true,
skipLibCheck: true,
strict: true,
noEmit: true,
esModuleInterop: true,
module: "esnext",
moduleResolution: "bundler",
resolveJsonModule: true,
isolatedModules: true,
jsx: "preserve",
incremental: true,
plugins: [{ name: "next" }],
paths: { "@/*": ["./src/*"] },
},
include: [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
],
exclude: ["node_modules"],
},
null,
2,
) + "\n"
);
}
function generateDockerComposeTest(): string {
return `# Docker Compose stack for e2e smoke testing with aimock.
# Usage: docker compose -f docker-compose.test.yml up -d
services:
aimock:
image: ghcr.io/copilotkit/aimock:latest
ports:
- "4010:4010"
volumes:
- ./fixtures:/fixtures:ro
command: ["--fixtures", "/fixtures", "--host", "0.0.0.0"]
app:
build:
context: .
dockerfile: Dockerfile
args:
INSTALL_MODE: fresh
ports:
- "3000:3000"
environment:
- OPENAI_API_KEY=test-key-for-aimock
- OPENAI_BASE_URL=http://aimock:4010/v1
depends_on:
aimock:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3000/"]
interval: 5s
timeout: 5s
retries: 30
start_period: 60s
`;
}
function generateDefaultFixtures(slug: string): string {
return JSON.stringify(
{
fixtures: [
{
match: { userMessage: "Hello" },
response: {
content: `Hello! I'm the ${slug} AI assistant. How can I help you?`,
},
},
{
match: {},
response: {
content:
"You're currently running against aimock (a mock LLM server). This response is a catch-all for requests that don't match any test fixture. To use a real LLM: (1) Add your OPENAI_API_KEY to .env, (2) Remove or unset OPENAI_BASE_URL from your environment so requests go to OpenAI instead of aimock, (3) Restart with `pnpm dev`.",
},
},
],
},
null,
2,
);
}
function generateGitignore(): string {
return `node_modules/
.next/
.env.local
.env
*.pyc
__pycache__/
.venv/
dist/
playwright-report/
test-results/
`;
}
function toPascalCase(str: string): string {
return str
.split("-")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join("");
}
async function main() {
const args = parseArgs();
const features = loadFeatureRegistry();
const packageDir = path.join(PACKAGES_DIR, args.slug);
if (fs.existsSync(packageDir)) {
console.error(`Error: Package directory already exists: ${packageDir}`);
process.exit(1);
}
console.log(`\nCreating integration package: ${args.name}\n`);
console.log(` Slug: ${args.slug}`);
console.log(` Category: ${args.category}`);
console.log(` Language: ${args.language}`);
console.log(` Features: ${args.features.join(", ")}`);
console.log("");
// Root files
writeFile(
path.join(packageDir, "manifest.yaml"),
generateManifest(args, features),
);
writeFile(path.join(packageDir, "package.json"), generatePackageJson(args));
writeFile(path.join(packageDir, "Dockerfile"), generateDockerfile(args));
writeFile(path.join(packageDir, "entrypoint.sh"), generateEntrypoint(args));
writeFile(path.join(packageDir, ".env.example"), generateEnvExample(args));
writeFile(path.join(packageDir, ".gitignore"), generateGitignore());
writeFile(path.join(packageDir, "next.config.ts"), generateNextConfig());
writeFile(path.join(packageDir, "tsconfig.json"), generateTsConfig());
writeFile(
path.join(packageDir, "postcss.config.mjs"),
generatePostcssConfig(),
);
writeFile(
path.join(packageDir, "playwright.config.ts"),
generatePlaywrightConfig(),
);
// E2E test infrastructure
writeFile(
path.join(packageDir, "docker-compose.test.yml"),
generateDockerComposeTest(),
);
writeFile(
path.join(packageDir, "fixtures", "default.json"),
generateDefaultFixtures(args.slug),
);
if (args.language !== "typescript") {
writeFile(
path.join(packageDir, "requirements.txt"),
generateRequirementsTxt(args),
);
writeFile(
path.join(packageDir, "src", "agent_server.py"),
generateAgentServer(args),
);
}
// App source
writeFile(
path.join(packageDir, "src", "app", "layout.tsx"),
generateLayout(),
);
writeFile(
path.join(packageDir, "src", "app", "globals.css"),
generateGlobalsCss(),
);
writeFile(
path.join(packageDir, "src", "app", "page.tsx"),
generateIndexPage(args, features),
);
writeFile(
path.join(packageDir, "src", "app", "api", "copilotkit", "route.ts"),
generateRuntimeRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "api", "health", "route.ts"),
generateHealthRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "api", "debug", "route.ts"),
generateDebugRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "api", "smoke", "route.ts"),
generateSmokeRoute(args),
);
writeFile(
path.join(packageDir, "src", "app", "demos", "error-boundary.tsx"),
generateErrorBoundary(),
);
// Demo stubs
for (const featureId of args.features) {
const feature = features.find((f) => f.id === featureId);
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "page.tsx"),
generateDemoPage(featureId, feature, args),
);
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "README.md"),
generateDemoReadme(featureId, feature),
);
if (args.language !== "typescript") {
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "agent.py"),
`"""
Agent implementation for ${feature?.name || featureId}
TODO: Implement the agent logic for ${args.name}
See the LangGraph Python reference implementation for patterns.
"""
`,
);
} else {
writeFile(
path.join(packageDir, "src", "app", "demos", featureId, "agent.ts"),
`/**
* Agent implementation for ${feature?.name || featureId}
*
* TODO: Implement the agent logic for ${args.name}
* See the LangGraph Python reference implementation for patterns.
*/
`,
);
}
// E2E test stub
writeFile(
path.join(packageDir, "tests", "e2e", `${featureId}.spec.ts`),
generateE2ETest(featureId, feature),
);
// QA template
writeFile(
path.join(packageDir, "qa", `${featureId}.md`),
generateQATemplate(featureId, feature, args),
);
}
console.log(`\nPackage created at: showcase/packages/${args.slug}/`);
// Auto-migrate agent code from examples/integrations/ if available
console.log("\n--- Migrating agent code from examples/integrations/ ---\n");
try {
const { migrateForSlug } =
await import("../migrate-integration-examples.js");
const migResult = migrateForSlug(args.slug);
if (migResult.errors.length > 0) {
console.error(` Migration FAILED for ${args.slug}:`);
for (const err of migResult.errors) console.error(` ${err}`);
process.exit(1);
} else if (migResult.files.length > 0) {
console.log(
` Migrated ${migResult.files.length} agent files from examples/integrations/`,
);
for (const f of migResult.files) console.log(` ${f}`);
} else if (migResult.skipped.length > 0) {
console.log(` No migration needed: ${migResult.skipped[0]}`);
}
} catch (e: any) {
console.log(` Migration skipped: ${e.message}`);
console.log(" (Run migrate-integration-examples.ts manually if needed)");
}
// Auto-update CI workflows to include this integration
console.log("\n--- Updating CI workflows ---\n");
updateWorkflows(args);
console.log("\nNext steps:");
console.log(" 1. Write/customize the agent code in src/agents/");
console.log(
" 2. Pin framework deps to exact versions from the Dojo example",
);
console.log(" 3. Fill in E2E test assertions");
console.log(
` 4. Deploy to Railway: npx tsx showcase/scripts/deploy-to-railway.ts ${args.slug}`,
);
console.log(
` 5. Go live: npx tsx showcase/scripts/deploy-to-railway.ts --go-live ${args.slug}`,
);
console.log(" 6. Open a PR to the monorepo\n");
}
function updateWorkflows(args: CLIArgs) {
const workflowsDir = path.resolve(ROOT, "..", ".github", "workflows");
// 1. Update showcase_deploy.yml — add change detection + build job
const deployPath = path.join(workflowsDir, "showcase_deploy.yml");
if (fs.existsSync(deployPath)) {
let deploy = fs.readFileSync(deployPath, "utf-8");
const slug = args.slug;
const slugVar = slug.replace(/-/g, "_");
// Add to workflow_dispatch options if not present
if (!deploy.includes(`- ${slug}`)) {
deploy = deploy.replace(
/(\s+options:\n(?:\s+- .+\n)+)/,
`$1 - ${slug}\n`,
);
}
// Add change detection filter if not present
if (!deploy.includes(`${slug}:`)) {
// Add output (match only lines containing ${{ to avoid matching `steps:`)
deploy = deploy.replace(
/(outputs:\n(?:\s+\w+:.*\$\{\{.*\n)+)/,
`$1 ${slugVar}: \${{ steps.changes.outputs.${slugVar} }}\n`,
);
// Add filter
deploy = deploy.replace(
/(filters: \|\n(?:\s+\w+:\n(?:\s+- .+\n)+)+)/,
`$1 ${slugVar}:\n - 'showcase/packages/${slug}/**'\n`,
);
}
// Add build job if not present
if (!deploy.includes(`build-${slugVar}`)) {
const buildJob = `
build-${slugVar}:
name: Build & Push ${args.name}
needs: [detect-changes, check-lockfile]
if: |
needs.detect-changes.outputs.${slugVar} == 'true' ||
github.event.inputs.service == '${slug}' ||
github.event.inputs.service == 'all'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: \${{ github.actor }}
password: \${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: showcase/packages/${slug}
push: true
tags: |
ghcr.io/copilotkit/showcase-${slug}:latest
ghcr.io/copilotkit/showcase-${slug}:\${{ github.sha }}
cache-from: type=gha,scope=${slugVar}
cache-to: type=gha,scope=${slugVar},mode=max
- name: Trigger Railway deploy
run: |
curl -sf -X POST https://backboard.railway.com/graphql/v2 \\
-H "Authorization: Bearer \${{ secrets.RAILWAY_TOKEN }}" \\
-H "Content-Type: application/json" \\
-d '{"query":"mutation { serviceInstanceRedeploy(serviceId: \\"RAILWAY_SERVICE_ID\\", environmentId: \\"b14919f4-6417-429f-848d-c6ae2201e04f\\") }"}' \\
&& echo "${slug} deploy triggered"
`;
deploy += buildJob;
}
fs.writeFileSync(deployPath, deploy);
console.log(" Updated showcase_deploy.yml");
}
// 2. Update showcase_drift-detection.yml — add to E2E matrix
const driftPath = path.join(workflowsDir, "showcase_drift-detection.yml");
if (fs.existsSync(driftPath)) {
let drift = fs.readFileSync(driftPath, "utf-8");
const slug = args.slug;
if (!drift.includes(`slug: ${slug}`)) {
// Add to matrix includes
const entry = ` - slug: ${slug}\n name: "${args.name}"\n url: https://showcase-${slug}-production.up.railway.app`;
drift = drift.replace(
/(matrix:\n\s+include:\n(?:\s+- slug:.*\n\s+name:.*\n\s+url:.*\n)+)/,
`$1${entry}\n`,
);
fs.writeFileSync(driftPath, drift);
console.log(" Updated showcase_drift-detection.yml");
}
}
// 3. Update starter-smoke.yml — add to matrix (block sequence format)
const smokePath = path.join(workflowsDir, "starter-smoke.yml");
if (fs.existsSync(smokePath)) {
let smoke = fs.readFileSync(smokePath, "utf-8");
const slug = args.slug;
// The matrix uses YAML block sequence format:
// starter:
// - langgraph-python
// - mastra
// Find the last `- <slug>` entry after `starter:` under `matrix:` and append.
const lines = smoke.split("\n");
let lastEntryIndex = -1;
let inStarterBlock = false;
let entryIndent = "";
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^\s+starter:\s*$/.test(line)) {
inStarterBlock = true;
continue;
}
if (inStarterBlock) {
const entryMatch = line.match(/^(\s+- )\S/);
if (entryMatch) {
lastEntryIndex = i;
entryIndent = entryMatch[1];
if (line.trim() === `- ${slug}`) {
// Already present
lastEntryIndex = -1;
break;
}
} else {
// End of block sequence
break;
}
}
}
if (lastEntryIndex >= 0) {
lines.splice(lastEntryIndex + 1, 0, `${entryIndent}${slug}`);
smoke = lines.join("\n");
fs.writeFileSync(smokePath, smoke);
console.log(" Updated starter-smoke.yml");
}
}
}
main();