7.15 Web app: Next.js and CopilotKit v2
Agents and the approval card are not hard-coded. Both come from the workflow YAML through the backend.
// web/package.json
{
"name": "bo-web", "private": true, "license": "MIT",
"scripts": {
"dev": "next dev", "build": "next build", "start": "next start",
"license:check": "license-checker-rseidelsohn --production --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;CC0-1.0;BlueOak-1.0.0;Python-2.0;CC-BY-4.0' --excludePrivatePackages"
},
"dependencies": {
"@copilotkit/react-core": "1.83.0",
"@copilotkit/runtime": "1.83.0",
"@ag-ui/client": "1.0.2",
"next": "16.4.3", "react": "19.2.0", "react-dom": "19.2.0"
},
"devDependencies": { "typescript": "5.9.3", "license-checker-rseidelsohn": "4.4.2" }
}
The CopilotKit and @ag-ui/client versions are the ones seen working together in CopilotKit issue #8192. Keep all @copilotkit/* packages on one version. The React and TypeScript patch versions are VERIFY.
// web/app/api/copilotkit/[[...slug]]/route.ts
import { HttpAgent } from "@ag-ui/client";
import { CopilotRuntime, InMemoryAgentRunner, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
const AGENT_URL = process.env.BO_AGENT_URL ?? "http://server:8080";
type AgentDef = { id: string; title: string };
let cached: { at: number; handler: (req: Request) => Promise<Response> } | null = null;
const TTL_MS = 60_000;
async function getHandler() {
if (cached && Date.now() - cached.at < TTL_MS) return cached.handler;
const defs: AgentDef[] = await fetch(`${AGENT_URL}/api/agents`, { cache: "no-store" }).then((r) => r.json());
const agents = Object.fromEntries(defs.map((d) => [d.id, new HttpAgent({ url: `${AGENT_URL}/agui/${d.id}` })]));
const handler = createCopilotRuntimeHandler({
runtime: new CopilotRuntime({ agents, runner: new InMemoryAgentRunner() }), // no CopilotKit Intelligence; VERIFY against 1.83
basePath: "/api/copilotkit",
});
cached = { at: Date.now(), handler };
return handler;
}
// TODO(impl): forward the user's JWT to the Loco app (HttpAgent headers) — VERIFY per-request header API
const route = async (req: Request) => (await getHandler())(req);
export const GET = route; export const POST = route; export const PATCH = route; export const DELETE = route;
// web/app/layout.tsx
"use client";
import { CopilotKit } from "@copilotkit/react-core/v2";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (<html lang="en"><body>
<CopilotKit runtimeUrl="/api/copilotkit" useSingleEndpoint={false}
enableInspector={process.env.NODE_ENV === "development"}>{children}</CopilotKit>
</body></html>);
}
// web/components/ApprovalCard.tsx — generic: fields come from `ui.approval_card` in the workflow YAML
"use client";
import { useInterrupt } from "@copilotkit/react-core/v2";
const at = (obj: any, path: string) => path.split(".").reduce((o, k) => o?.[k], obj);
export function ApprovalCard({ state }: { state: any }) {
useInterrupt({
// VERIFY: event shape for AG-UI 1.0 interrupt outcomes vs the legacy on_interrupt custom event
render: ({ event, resolve }: any) => {
const card = event?.value?.metadata?.ui?.approval_card ?? {};
const title = String(card.title ?? "Approval needed")
.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_: string, p: string) => String(at(state, p) ?? ""));
return (
<div className="rounded border p-4">
<h3>{title}</h3>
<dl>{(card.fields ?? []).map((f: string) => (
<div key={f}><dt>{f}</dt><dd>{JSON.stringify(at(state, f))}</dd></div>))}</dl>
{/* TODO(impl): inputs for card.editable, returned as payload fields */}
<button onClick={() => resolve({ approved: true })}>Approve</button>
<button onClick={() => resolve({ approved: false })}>Reject</button>
</div>);
},
});
return null;
}
// web/app/page.tsx
"use client";
import { CopilotChat, useAgent } from "@copilotkit/react-core/v2";
import { ApprovalCard } from "@/components/ApprovalCard";
export default function Page() {
const agentId = "invoice_v1"; // TODO(impl): picker fed by GET /api/agents
const { agent } = useAgent({ agentId }); // shared state from STATE_SNAPSHOT events
const s: any = agent.state ?? {};
return (<main className="grid grid-cols-3 gap-4 h-screen">
<section className="col-span-2 p-4">
<h2>Current run</h2>
<pre>{JSON.stringify(s, null, 2)}</pre>
<p>Status: {agent.isRunning ? "running" : s.awaiting_approval ? "awaiting approval" : "idle"}</p>
</section>
<aside className="border-l"><ApprovalCard state={s} /><CopilotChat agentId={agentId} className="h-full" /></aside>
</main>);
}