Sfoglia il codice sorgente

Add harness-chat example (#302)

* Add harness-chat example: json-render as the UI for agent harnesses

A Claude Code agent runs in a Vercel Sandbox via AI SDK 7's experimental HarnessAgent and reports its work (steps, file changes, terminal output, test results) as a streamed json-render spec. Because HarnessAgent.stream() returns a standard StreamTextResult, the existing pipeJsonRender -> useJsonRenderMessage pipeline works unchanged.

The example pins matching AI SDK 7 canary packages so the harness adapter, React hooks, and Vercel sandbox transport resolve together.

* harness-chat: fix dev-runtime issues found in live run

- allowedDevOrigins for the portless proxy origin (Next 16 silently fails to hydrate on non-allowlisted cross-origin dev requests)
- serverExternalPackages for the harness adapter, which loads its sandbox bridge via new URL(..., import.meta.url) and cannot be bundled
- README: document CLI-login (TTY) vs OIDC sandbox auth paths

* harness-chat: agent picker, charts, and design polish

- Add a UI agent selector (Claude Code / Codex / Pi) with per-agent harness sessions and monochrome brand marks
- Add monochrome bar/line chart components to the catalog and registry
- Polish the chat shell: minimalist Geist/Geist-Mono design, tool-call icons + containers, inline-code shading, and a working text shimmer

* harness-chat: add mermaid diagrams to README

- Replace the ASCII data-flow sketch with a mermaid flowchart and a sequence diagram of a turn
- Update prose/setup to reflect the three-agent selector (Claude Code / Codex / Pi)

* Fix harness chat reset and signed charts
Chris Tate 3 settimane fa
parent
commit
e2d00faeaa

+ 78 - 0
examples/harness-chat/README.md

@@ -0,0 +1,78 @@
+# Harness Agent Chat Example
+
+**json-render as the UI for agent harnesses.**
+
+This example runs a real coding agent -- pick **Claude Code**, **Codex**, or **Pi** -- in a Vercel Sandbox, driven through the AI SDK 7 [`HarnessAgent`](https://vercel.com/changelog/program-agent-harnesses-with-ai-sdk) API, and renders its work as generative UI instead of a wall of markdown.
+
+The agent edits files, runs commands, and executes tests inside the sandbox. When it reports back, it emits a json-render spec constrained to a catalog of work-report components (`Steps`, `FileChange`, `Terminal`, `TestResults`, `Metric`, `BarChart`, `LineChart`, ...), which streams into the chat as structured, rendered UI.
+
+## How it works
+
+The round trip: the browser posts the chosen agent and prompt, the server streams a harness turn, and `pipeJsonRender` extracts the spec fence on the way back.
+
+```mermaid
+flowchart LR
+  subgraph browser [Browser]
+    UI["app/page.tsx<br/>useChat · AgentSelector"]
+  end
+  subgraph server ["app/api/agent/route.ts"]
+    SESS["getSession(chatId, agent)"]
+    AGENT["HarnessAgent<br/>Claude Code · Codex · Pi"]
+    SANDBOX[("Vercel Sandbox")]
+    PIPE["pipeJsonRender"]
+  end
+  UI -- "POST { messages, agent }" --> SESS
+  SESS --> AGENT
+  AGENT <-->|"bash · edit · test"| SANDBOX
+  AGENT -- "toUIMessageStream()" --> PIPE
+  PIPE -- "text · tool calls · data-spec parts" --> UI
+```
+
+What happens on a single turn:
+
+```mermaid
+sequenceDiagram
+  participant U as User
+  participant UI as page.tsx
+  participant API as /api/agent
+  participant AG as HarnessAgent
+  participant SB as Sandbox
+  U->>UI: pick agent + send prompt
+  UI->>API: POST { messages, agent }
+  API->>AG: getSession + stream(prompt)
+  AG->>SB: run commands / edit files
+  SB-->>AG: output
+  AG-->>API: prose + spec fence (streamed)
+  Note over API: pipeJsonRender splits the spec fence out
+  API-->>UI: text · tool calls · data-spec parts
+  UI-->>U: markdown + rendered report
+```
+
+1. `lib/agents.ts` is a client-safe catalog of the selectable agents; `lib/agent.ts` builds a `HarnessAgent` per agent (Claude Code, Codex, or Pi), each with a Vercel sandbox provider. The shared `instructions` embed `agentReportCatalog.prompt({ mode: "inline" })`, teaching the runtime to wrap its UI report in a ` ```spec ` fence.
+2. `app/api/agent/route.ts` reads the chosen `agent` from the request body and keeps one live harness session per chat, locked to the agent that created it (the harness owns its own conversation history, so each turn sends only the fresh user message). It streams the turn and pipes it through `pipeJsonRender` -- which extracts the spec fence into typed `data-spec` parts while passing text and tool calls through untouched.
+3. `app/page.tsx` renders text with markdown, builtin tool calls (bash, edit, ...) as activity lines, and the spec inline with `<ReportRenderer>` via `useJsonRenderMessage`. The `AgentSelector` on the first screen chooses which harness to run.
+
+Because `HarnessAgent.stream()` returns a standard AI SDK `StreamTextResult`, the json-render pipeline is identical to the single-model [chat example](../chat) -- swapping a model call for a full agent harness changes nothing about the UI layer.
+
+## Setup
+
+The AI SDK harness packages are **experimental canary releases**; expect breaking changes.
+
+1. Give the sandbox provider Vercel credentials, either:
+   - Be logged in with the Vercel CLI (`vercel login`) and run the dev server in a terminal (the SDK only falls back to CLI auth when attached to a TTY). It uses or creates a `vercel-sandbox-default-project` in your personal scope.
+   - Or link a project and pull an OIDC token: `vercel link && vercel env pull`.
+
+2. Provide model credentials. `AI_GATEWAY_API_KEY` (Vercel AI Gateway) works for all three agents. Or use a provider key directly for the agent you run: `ANTHROPIC_API_KEY` (Claude Code) or `OPENAI_API_KEY` (Codex); Pi resolves credentials from the gateway.
+
+3. Optionally pin a model per agent: `CLAUDE_CODE_MODEL`, `CODEX_MODEL`, or `PI_MODEL` (each defaults to that runtime's own default).
+
+## Run
+
+```bash
+pnpm install
+pnpm dev
+```
+
+Then open `harness-chat-demo.json-render.localhost:1355`.
+
+Note: the first message in a chat boots a fresh sandbox, which takes a while; follow-up messages reuse it. "Start Over" destroys the server-side session and its sandbox; idle sessions are destroyed after 10 minutes.

+ 64 - 0
examples/harness-chat/app/api/agent/route.ts

@@ -0,0 +1,64 @@
+import {
+  createUIMessageStream,
+  createUIMessageStreamResponse,
+  type UIMessage,
+} from "ai";
+import { pipeJsonRender } from "@json-render/core";
+import { getSession, dropSession } from "@/lib/agent";
+import { DEFAULT_AGENT_ID, isAgentId } from "@/lib/agents";
+
+// Harness turns are long: the agent boots a sandbox, edits files, and runs
+// commands before answering.
+export const maxDuration = 600;
+
+function lastUserText(messages: UIMessage[]): string | null {
+  for (let i = messages.length - 1; i >= 0; i--) {
+    const message = messages[i];
+    if (message?.role !== "user") continue;
+    const text = message.parts
+      .filter((part) => part.type === "text")
+      .map((part) => part.text)
+      .join("\n")
+      .trim();
+    return text.length > 0 ? text : null;
+  }
+  return null;
+}
+
+export async function POST(req: Request) {
+  const body = await req.json();
+  const chatId: string = body.id ?? "default";
+  const messages: UIMessage[] = body.messages ?? [];
+
+  const prompt = lastUserText(messages);
+  if (!prompt) {
+    return new Response(
+      JSON.stringify({ error: "a user message with text is required" }),
+      { status: 400, headers: { "Content-Type": "application/json" } },
+    );
+  }
+
+  // The agent is chosen on the first message and locked for the chat: an
+  // existing session ignores `body.agent` and keeps its original agent.
+  const requestedAgent = isAgentId(body.agent) ? body.agent : DEFAULT_AGENT_ID;
+
+  // The harness session owns its own conversation history, so each turn
+  // sends only the fresh user input -- not the whole transcript.
+  const { session, agent } = await getSession(chatId, requestedAgent);
+  const result = await agent.stream({ prompt, session });
+
+  const stream = createUIMessageStream({
+    execute: async ({ writer }) => {
+      writer.merge(pipeJsonRender(result.toUIMessageStream()));
+    },
+  });
+
+  return createUIMessageStreamResponse({ stream });
+}
+
+export async function DELETE(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const chatId = searchParams.get("id") ?? "default";
+  dropSession(chatId);
+  return new Response(null, { status: 204 });
+}

BIN
examples/harness-chat/app/favicon.ico


+ 161 - 0
examples/harness-chat/app/globals.css

@@ -0,0 +1,161 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../../../node_modules/streamdown/dist/*.js";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+}
+
+:root {
+  --radius: 0.625rem;
+
+  /* Restrained neutrals. Craft comes from type and spacing, not color. */
+  --background: oklch(0.994 0 0);
+  --foreground: oklch(0.205 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.205 0 0);
+  --primary: oklch(0.205 0 0);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.97 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.973 0 0);
+  --muted-foreground: oklch(0.553 0 0);
+  --accent: oklch(0.968 0 0);
+  --accent-foreground: oklch(0.205 0 0);
+  --destructive: oklch(0.585 0.2 27.3);
+  --border: oklch(0.917 0 0);
+  --input: oklch(0.917 0 0);
+  --ring: oklch(0.708 0 0);
+
+  /* Muted, functional data colors — used only when a chart names a tone. */
+  --chart-1: oklch(0.5 0.13 277);
+  --chart-2: oklch(0.6 0.12 162);
+  --chart-3: oklch(0.7 0.12 70);
+  --chart-4: oklch(0.58 0.12 248);
+}
+
+.dark {
+  --background: oklch(0.165 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --card-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.985 0 0);
+  --primary-foreground: oklch(0.205 0 0);
+  --secondary: oklch(0.265 0 0);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.265 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --accent: oklch(0.27 0 0);
+  --accent-foreground: oklch(0.985 0 0);
+  --destructive: oklch(0.704 0.191 22.2);
+  --border: oklch(1 0 0 / 9%);
+  --input: oklch(1 0 0 / 13%);
+  --ring: oklch(0.556 0 0);
+  --chart-1: oklch(0.62 0.13 277);
+  --chart-2: oklch(0.68 0.12 162);
+  --chart-3: oklch(0.76 0.12 70);
+  --chart-4: oklch(0.66 0.12 248);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+}
+
+/* Map Tailwind's font tokens to Geist. next/font sets --font-geist-* on
+   <body>, so this must live on body (not :root, where those vars are
+   undefined) for `font-sans`/`font-mono` to resolve to Geist / Geist Mono. */
+body {
+  --font-sans: var(--font-geist-sans);
+  --font-mono: var(--font-geist-mono);
+}
+
+button {
+  cursor: pointer;
+}
+
+/* One restrained elevation step — a hairline, not a drop shadow. */
+.shadow-subtle {
+  box-shadow:
+    0 1px 1px oklch(0 0 0 / 0.04),
+    0 2px 6px -2px oklch(0 0 0 / 0.06);
+}
+
+/* Inline code in agent markdown gets a shaded pill. `:not(pre) > code` targets
+   only inline code, leaving fenced code blocks (Shiki) untouched. The tint is
+   derived from the muted-foreground so it reads in both light and dark mode. */
+.markdown :not(pre) > code {
+  border-radius: 0.375rem;
+  background-color: color-mix(in oklab, var(--muted-foreground) 16%, transparent);
+  padding: 0.1em 0.35em;
+  font-size: 0.85em;
+  font-family: var(--font-mono);
+}
+
+/* A bright band sweeps across dim text. `inline-block` is essential: it sizes
+   the gradient to the text itself, so the band actually passes over the words
+   (on a full-width block the band rarely reaches the short label). */
+@keyframes shimmer {
+  0% {
+    background-position: 100% 0;
+  }
+  100% {
+    background-position: 0% 0;
+  }
+}
+
+.animate-shimmer {
+  display: inline-block;
+  color: transparent;
+  background-image: linear-gradient(
+    90deg,
+    var(--muted-foreground) 0%,
+    var(--muted-foreground) 40%,
+    var(--foreground) 50%,
+    var(--muted-foreground) 60%,
+    var(--muted-foreground) 100%
+  );
+  background-size: 300% 100%;
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+  animation: shimmer 1.5s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .animate-shimmer {
+    animation: none;
+    color: var(--muted-foreground);
+    -webkit-text-fill-color: var(--muted-foreground);
+  }
+}

+ 36 - 0
examples/harness-chat/app/layout.tsx

@@ -0,0 +1,36 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "streamdown/styles.css";
+import "./globals.css";
+
+const geistSans = Geist({
+  variable: "--font-geist-sans",
+  subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+  variable: "--font-geist-mono",
+  subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+  title: "json-render Harness Agent Example",
+  description:
+    "A coding agent harness (Claude Code) that reports its work as generative UI via json-render",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en" suppressHydrationWarning>
+      <body
+        className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
+      >
+        {children}
+      </body>
+    </html>
+  );
+}

+ 583 - 0
examples/harness-chat/app/page.tsx

@@ -0,0 +1,583 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { useChat } from "@ai-sdk/react";
+import { DefaultChatTransport, type UIMessage } from "ai";
+import {
+  SPEC_DATA_PART,
+  SPEC_DATA_PART_TYPE,
+  type SpecDataPart,
+} from "@json-render/core";
+import { useJsonRenderMessage } from "@json-render/react";
+import {
+  ArrowUp,
+  Bug,
+  ChevronRight,
+  FilePen,
+  FilePlus,
+  FileText,
+  FolderGit2,
+  FolderSearch,
+  FolderTree,
+  Gauge,
+  Globe,
+  Hammer,
+  ListChecks,
+  Loader2,
+  Search,
+  SquareChevronRight,
+  Wrench,
+  type LucideIcon,
+} from "lucide-react";
+import { Streamdown } from "streamdown";
+import { code } from "@streamdown/code";
+
+import { ReportRenderer } from "@/lib/render/renderer";
+import {
+  AGENT_IDS,
+  AGENTS,
+  type AgentId,
+  DEFAULT_AGENT_ID,
+} from "@/lib/agents";
+
+type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
+type AppMessage = UIMessage<unknown, AppDataParts>;
+
+const transport = new DefaultChatTransport({ api: "/api/agent" });
+
+const SUGGESTIONS: Array<{
+  label: string;
+  description: string;
+  icon: LucideIcon;
+  prompt: string;
+}> = [
+  {
+    label: "Build & test a library",
+    description: "Scaffold a TS package with vitest and run the suite.",
+    icon: Hammer,
+    prompt:
+      "Scaffold a tiny TypeScript semver-parsing library with vitest tests, run the tests, and report the results.",
+  },
+  {
+    label: "Fix failing code",
+    description: "Plant a subtle bug, then debug it end to end.",
+    icon: Bug,
+    prompt:
+      "Create a small JS module with a subtle off-by-one bug and a failing test, then diagnose and fix it like a real debugging session.",
+  },
+  {
+    label: "Benchmark something",
+    description: "Measure two approaches and chart the numbers.",
+    icon: Gauge,
+    prompt:
+      "Write and run a quick benchmark comparing JSON.parse vs a streaming JSON parser on a 5MB file, and report the numbers as a bar chart.",
+  },
+  {
+    label: "Explore a repo",
+    description: "Clone a project and map out what each part does.",
+    icon: FolderGit2,
+    prompt:
+      "Clone github.com/vercel-labs/json-render, explore the package structure, and report what each package does.",
+  },
+];
+
+/** Per-tool icon + readable [running, done] labels (labels used for a11y). */
+const TOOL_META: Record<
+  string,
+  { icon: LucideIcon; labels: [string, string] }
+> = {
+  bash: {
+    icon: SquareChevronRight,
+    labels: ["Running command", "Ran command"],
+  },
+  read: { icon: FileText, labels: ["Reading file", "Read file"] },
+  write: { icon: FilePlus, labels: ["Writing file", "Wrote file"] },
+  edit: { icon: FilePen, labels: ["Editing file", "Edited file"] },
+  grep: { icon: Search, labels: ["Searching code", "Searched code"] },
+  glob: { icon: FolderSearch, labels: ["Listing files", "Listed files"] },
+  ls: { icon: FolderTree, labels: ["Listing directory", "Listed directory"] },
+  webSearch: { icon: Globe, labels: ["Searching the web", "Searched the web"] },
+  WebFetch: { icon: Globe, labels: ["Fetching page", "Fetched page"] },
+  TodoWrite: { icon: ListChecks, labels: ["Updating plan", "Updated plan"] },
+};
+
+/** Pull a one-line human hint out of a tool input (command, path, query). */
+function toolInputHint(input: unknown): string | null {
+  if (input == null || typeof input !== "object") return null;
+  const record = input as Record<string, unknown>;
+  const hint =
+    record.command ?? record.file_path ?? record.pattern ?? record.query;
+  return typeof hint === "string" ? hint : null;
+}
+
+function ToolCallDisplay({
+  toolName,
+  state,
+  input,
+  output,
+}: {
+  toolName: string;
+  state: string;
+  input: unknown;
+  output: unknown;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const isLoading =
+    state !== "output-available" &&
+    state !== "output-error" &&
+    state !== "output-denied";
+  const meta = TOOL_META[toolName];
+  const Icon = meta?.icon ?? Wrench;
+  const label = meta ? meta.labels[isLoading ? 0 : 1] : toolName;
+  const hint = toolInputHint(input);
+
+  return (
+    <div className="group rounded-lg border bg-card px-3 py-2 text-sm shadow-subtle">
+      <button
+        type="button"
+        title={label}
+        aria-label={label}
+        className="flex w-full max-w-full items-center gap-2 text-left"
+        onClick={() => setExpanded((e) => !e)}
+      >
+        <Icon
+          className={`size-3.5 shrink-0 text-muted-foreground ${
+            isLoading && !hint ? "animate-pulse" : ""
+          }`}
+          strokeWidth={1.75}
+        />
+        {hint && (
+          <code
+            className={`truncate font-mono text-xs text-muted-foreground/70 ${
+              isLoading ? "animate-shimmer" : ""
+            }`}
+          >
+            {hint}
+          </code>
+        )}
+        {!isLoading && output != null && (
+          <ChevronRight
+            className={`ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground ${expanded ? "rotate-90" : ""}`}
+          />
+        )}
+      </button>
+      {expanded && !isLoading && output != null && (
+        <pre className="mt-2 max-h-64 overflow-auto border-t pt-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
+          {typeof output === "string"
+            ? output
+            : JSON.stringify(output, null, 2)}
+        </pre>
+      )}
+    </div>
+  );
+}
+
+/**
+ * Monochrome agent marks. Claude (svgl) and OpenAI (svgl) are single-path
+ * brand glyphs forced to `currentColor`; Pi uses its namesake π since it has
+ * no published logo. All inherit the surrounding text color.
+ */
+type MarkProps = { className?: string };
+
+function ClaudeMark({ className }: MarkProps) {
+  return (
+    <svg viewBox="0 0 256 257" className={className} aria-hidden="true">
+      <path
+        fill="currentColor"
+        d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"
+      />
+    </svg>
+  );
+}
+
+function OpenAIMark({ className }: MarkProps) {
+  return (
+    <svg viewBox="0 0 256 260" className={className} aria-hidden="true">
+      <path
+        fill="currentColor"
+        d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"
+      />
+    </svg>
+  );
+}
+
+function PiMark({ className }: MarkProps) {
+  // pi.dev/logo-auto.svg — blocky "Pi" mark. The viewBox is padded a little
+  // past the mark's bounds so it reads slightly smaller than the other marks.
+  return (
+    <svg
+      viewBox="120 120 560 560"
+      className={className}
+      fill="currentColor"
+      aria-hidden="true"
+    >
+      <path
+        fillRule="evenodd"
+        d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"
+      />
+      <path d="M517.36 400H634.72V634.72H517.36Z" />
+    </svg>
+  );
+}
+
+const AGENT_MARKS: Record<AgentId, (props: MarkProps) => React.ReactNode> = {
+  "claude-code": ClaudeMark,
+  codex: OpenAIMark,
+  pi: PiMark,
+};
+
+/** Segmented control for picking the coding agent before a chat starts. */
+function AgentSelector({
+  value,
+  onChange,
+}: {
+  value: AgentId;
+  onChange: (id: AgentId) => void;
+}) {
+  return (
+    <div className="inline-flex items-center gap-0.5 rounded-lg border bg-card p-0.5">
+      {AGENT_IDS.map((id) => {
+        const Mark = AGENT_MARKS[id];
+        return (
+          <button
+            key={id}
+            type="button"
+            onClick={() => onChange(id)}
+            aria-pressed={value === id}
+            className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
+              value === id
+                ? "bg-foreground text-background"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+          >
+            <Mark className="h-3.5 w-3.5" />
+            {AGENTS[id].label}
+          </button>
+        );
+      })}
+    </div>
+  );
+}
+
+/** Shimmering status line shown while we wait for the agent to produce output. */
+function PendingLine({ label }: { label: string }) {
+  return (
+    <div className="text-sm text-muted-foreground animate-shimmer">{label}</div>
+  );
+}
+
+function MessageBubble({
+  message,
+  isLast,
+  isStreaming,
+  pendingLabel,
+}: {
+  message: AppMessage;
+  isLast: boolean;
+  isStreaming: boolean;
+  pendingLabel: string;
+}) {
+  const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
+
+  if (message.role === "user") {
+    return (
+      <div className="flex justify-end">
+        <div className="max-w-[85%] rounded-2xl rounded-tr-sm bg-foreground px-3.5 py-2 text-sm leading-relaxed whitespace-pre-wrap text-background">
+          {text}
+        </div>
+      </div>
+    );
+  }
+
+  // Ordered segments: adjacent text merged, adjacent tool calls grouped,
+  // the spec rendered inline where the agent emitted it.
+  const segments: Array<
+    | { kind: "text"; text: string }
+    | {
+        kind: "tools";
+        tools: Array<{
+          toolCallId: string;
+          toolName: string;
+          state: string;
+          input: unknown;
+          output: unknown;
+        }>;
+      }
+    | { kind: "spec" }
+  > = [];
+  let specInserted = false;
+
+  for (const part of message.parts) {
+    if (part.type === "text") {
+      if (!part.text.trim()) continue;
+      const last = segments[segments.length - 1];
+      if (last?.kind === "text") last.text += part.text;
+      else segments.push({ kind: "text", text: part.text });
+    } else if (part.type.startsWith("tool-")) {
+      const tp = part as {
+        type: string;
+        toolCallId: string;
+        state: string;
+        input?: unknown;
+        output?: unknown;
+      };
+      const tool = {
+        toolCallId: tp.toolCallId,
+        toolName: tp.type.replace(/^tool-/, ""),
+        state: tp.state,
+        input: tp.input,
+        output: tp.output,
+      };
+      const last = segments[segments.length - 1];
+      if (last?.kind === "tools") last.tools.push(tool);
+      else segments.push({ kind: "tools", tools: [tool] });
+    } else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
+      segments.push({ kind: "spec" });
+      specInserted = true;
+    }
+  }
+
+  const showLoader = isLast && isStreaming && segments.length === 0 && !hasSpec;
+
+  return (
+    <div className="flex w-full flex-col gap-3">
+      {segments.map((seg, i) => {
+        if (seg.kind === "text") {
+          return (
+            <div key={`text-${i}`} className="markdown text-sm leading-relaxed">
+              <Streamdown
+                plugins={{ code }}
+                animated={isLast && isStreaming && i === segments.length - 1}
+              >
+                {seg.text}
+              </Streamdown>
+            </div>
+          );
+        }
+        if (seg.kind === "spec") {
+          if (!hasSpec) return null;
+          return (
+            <div key="spec" className="w-full">
+              <ReportRenderer spec={spec} loading={isLast && isStreaming} />
+            </div>
+          );
+        }
+        return (
+          <div key={`tools-${i}`} className="flex flex-col gap-1.5">
+            {seg.tools.map((t) => (
+              <ToolCallDisplay
+                key={t.toolCallId}
+                toolName={t.toolName}
+                state={t.state}
+                input={t.input}
+                output={t.output}
+              />
+            ))}
+          </div>
+        );
+      })}
+
+      {showLoader && <PendingLine label={pendingLabel} />}
+
+      {hasSpec && !specInserted && (
+        <div className="w-full">
+          <ReportRenderer spec={spec} loading={isLast && isStreaming} />
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default function HarnessChatPage() {
+  const [input, setInput] = useState("");
+  const [agentId, setAgentId] = useState<AgentId>(DEFAULT_AGENT_ID);
+  const [chatId, setChatId] = useState(() => crypto.randomUUID());
+  const [isResetting, setIsResetting] = useState(false);
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+
+  const { messages, sendMessage, setMessages, status, error, id, stop } =
+    useChat<AppMessage>({ transport, id: chatId });
+
+  const isStreaming = status === "streaming" || status === "submitted";
+  const isBusy = isStreaming || isResetting;
+
+  const handleSubmit = useCallback(
+    async (text?: string) => {
+      const message = text || input;
+      if (!message.trim() || isBusy) return;
+      setInput("");
+      // The server locks the agent to the chat on the first message; sending
+      // it every turn is harmless and keeps follow-ups consistent.
+      await sendMessage({ text: message.trim() }, { body: { agent: agentId } });
+    },
+    [input, isBusy, sendMessage, agentId],
+  );
+
+  const handleClear = useCallback(async () => {
+    if (isResetting) return;
+    setIsResetting(true);
+    stop();
+
+    // Drop the server-side harness session (and its sandbox) for this chat.
+    try {
+      await fetch(`/api/agent?id=${encodeURIComponent(id)}`, {
+        method: "DELETE",
+      });
+    } finally {
+      setMessages([]);
+      setChatId(crypto.randomUUID());
+      setInput("");
+      setIsResetting(false);
+      inputRef.current?.focus();
+    }
+  }, [id, isResetting, setMessages, stop]);
+
+  const isEmpty = messages.length === 0;
+
+  return (
+    <div className="flex h-screen flex-col overflow-hidden">
+      {/* The header only appears once a chat has started; the first screen is
+          headerless so the brand title carries it. */}
+      {!isEmpty && (
+        <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center justify-between border-b bg-background/80 px-5 backdrop-blur-md">
+          {/* Left: active agent */}
+          {(() => {
+            const Mark = AGENT_MARKS[agentId];
+            return (
+              <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
+                <Mark className="h-3.5 w-3.5" />
+                {AGENTS[agentId].label}
+              </span>
+            );
+          })()}
+          {/* Center: brand, absolutely centered so side widths can't shift it */}
+          <h1 className="pointer-events-none absolute left-1/2 -translate-x-1/2 text-sm font-medium tracking-tight whitespace-nowrap text-muted-foreground">
+            AI SDK <span className="font-mono">HarnessAgent</span>
+            <span className="mx-1.5 font-normal">+</span>
+            <span className="font-mono">json-render</span>
+          </h1>
+          {/* Right: reset */}
+          <button
+            onClick={handleClear}
+            disabled={isResetting}
+            className="-mr-1.5 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
+          >
+            Start over
+          </button>
+        </header>
+      )}
+
+      <main className="flex flex-1 flex-col overflow-auto">
+        {isEmpty ? (
+          <div className="flex flex-1 flex-col items-center justify-center px-6">
+            <div className="w-full max-w-3xl">
+              <div className="space-y-3">
+                <h2 className="text-3xl font-semibold tracking-tight">
+                  AI SDK <span className="font-mono">HarnessAgent</span>
+                  <span className="mx-1.5 font-normal text-muted-foreground">
+                    +
+                  </span>
+                  <span className="font-mono">json-render</span>
+                </h2>
+                <p className="max-w-md text-[15px] leading-relaxed text-muted-foreground">
+                  A coding agent works in a live sandbox, then reports back as
+                  rendered UI — steps, diffs, terminal output, tests, and charts
+                  — instead of a wall of markdown.
+                </p>
+              </div>
+              <div className="mt-8 grid grid-cols-1 gap-px overflow-hidden rounded-xl border bg-border sm:grid-cols-2">
+                {SUGGESTIONS.map((s) => {
+                  const Icon = s.icon;
+                  return (
+                    <button
+                      key={s.label}
+                      onClick={() => handleSubmit(s.prompt)}
+                      disabled={isBusy}
+                      className="group flex items-start gap-3 bg-card p-4 text-left transition-colors hover:bg-accent"
+                    >
+                      <Icon
+                        className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
+                        strokeWidth={1.75}
+                      />
+                      <span className="min-w-0 space-y-0.5">
+                        <span className="block text-sm font-medium tracking-tight">
+                          {s.label}
+                        </span>
+                        <span className="block text-[13px] leading-snug text-muted-foreground">
+                          {s.description}
+                        </span>
+                      </span>
+                    </button>
+                  );
+                })}
+              </div>
+            </div>
+          </div>
+        ) : (
+          <div className="mx-auto w-full max-w-3xl space-y-6 px-6 py-6">
+            {messages.map((message, index) => (
+              <MessageBubble
+                key={message.id}
+                message={message}
+                isLast={index === messages.length - 1}
+                isStreaming={isStreaming}
+                pendingLabel={index <= 1 ? "Starting sandbox…" : "Working…"}
+              />
+            ))}
+            {isStreaming && messages[messages.length - 1]?.role === "user" && (
+              <PendingLine
+                label={messages.length <= 1 ? "Starting sandbox…" : "Working…"}
+              />
+            )}
+            {error && (
+              <div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
+                {error.message}
+              </div>
+            )}
+          </div>
+        )}
+      </main>
+
+      <div className="shrink-0 px-6 pb-5">
+        {isEmpty && (
+          <div className="mx-auto mb-2.5 flex max-w-3xl items-center gap-2">
+            <span className="text-xs text-muted-foreground">Agent</span>
+            <AgentSelector value={agentId} onChange={setAgentId} />
+          </div>
+        )}
+        <div className="group relative mx-auto max-w-3xl rounded-xl border bg-card shadow-subtle transition-colors focus-within:border-ring">
+          <textarea
+            ref={inputRef}
+            value={input}
+            onChange={(e) => setInput(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Enter" && !e.shiftKey) {
+                e.preventDefault();
+                handleSubmit();
+              }
+            }}
+            placeholder={
+              isEmpty
+                ? "Scaffold a TypeScript library and run its tests…"
+                : "Ask a follow-up…"
+            }
+            rows={2}
+            className="w-full resize-none bg-transparent px-3.5 py-3 pr-12 text-sm leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none"
+            autoFocus
+          />
+          <button
+            onClick={() => handleSubmit()}
+            disabled={!input.trim() || isBusy}
+            className="absolute right-2.5 bottom-2.5 flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-25"
+          >
+            {isBusy ? (
+              <Loader2 className="h-3.5 w-3.5 animate-spin" />
+            ) : (
+              <ArrowUp className="h-3.5 w-3.5" strokeWidth={2.25} />
+            )}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 11 - 0
examples/harness-chat/eslint.config.js

@@ -0,0 +1,11 @@
+import { nextJsConfig } from "@internal/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+    },
+  },
+];

+ 161 - 0
examples/harness-chat/lib/agent.ts

@@ -0,0 +1,161 @@
+import {
+  HarnessAgent,
+  type HarnessAgentAdapter,
+  type HarnessAgentSession,
+} from "@ai-sdk/harness/agent";
+import { createClaudeCode } from "@ai-sdk/harness-claude-code";
+import { createCodex } from "@ai-sdk/harness-codex";
+import { createPi } from "@ai-sdk/harness-pi";
+import { createVercelSandbox } from "@ai-sdk/sandbox-vercel";
+import { agentReportCatalog } from "./render/catalog";
+import { type AgentId } from "./agents";
+
+const AGENT_INSTRUCTIONS = `You are a coding agent running inside a fresh Linux sandbox with Node.js available. The user gives you software tasks; you do the work with your tools (bash, file edits, web search), then report back.
+
+REPORTING:
+Your chat output is rendered in a web UI that understands a JSON component spec. After finishing the work for a turn:
+1. Write one or two short conversational sentences about the outcome.
+2. Then output a UI report as a JSONL spec wrapped in a \`\`\`spec fence.
+
+Make the report reflect what actually happened, drawing from your real session:
+- Steps for the plan you executed (statuses: done/error; use active/pending only if work remains).
+- FileChange entries for files you created, modified, or deleted.
+- Terminal for important commands you ran and their real output (trim long output).
+- TestResults when you ran a test suite.
+- Metric for headline numbers (files changed, tests passed, duration).
+- BarChart to compare numbers across labeled categories (e.g. bundle size per module, benchmark per case).
+- LineChart for a number that changes across an ordered sequence (e.g. coverage per commit, latency over runs).
+- CodeBlock for the key snippet worth showing, with the file path as title.
+- Callout for risks, caveats, or suggested follow-ups.
+- Group sections with Card; never nest Cards.
+
+Never invent results. If something failed, show it (error step, non-zero exit, failed tests) and say what you would try next.
+
+Never use emojis -- not in prose, headings, labels, callouts, or any text field. The UI components supply their own icons.
+
+${agentReportCatalog.prompt({
+  mode: "inline",
+  customRules: [
+    "Keep reports compact and information-dense; the UI renders inside a chat thread.",
+    "Prefer Grid with columns='2' or '3' for Metric rows.",
+    "Use real command output captured during the session in Terminal components.",
+    "Never put emojis in any text field; the components already provide icons.",
+  ],
+})}`;
+
+const gatewayKey = process.env.AI_GATEWAY_API_KEY;
+
+// Each agent runs in its own fresh Node sandbox.
+const sandbox = () => createVercelSandbox({ runtime: "node24", ports: [3000] });
+
+/**
+ * Build the HarnessAgent for an agent id. Both adapters need a `as unknown`
+ * cast on current canaries: they pin zod@3 while the rest of the tree resolves
+ * zod@4, so their HarnessV1 type carries a different provider-utils instance.
+ * Type-level only.
+ */
+function createAgent(id: AgentId): HarnessAgent {
+  if (id === "codex") {
+    const auth = gatewayKey
+      ? { gateway: { apiKey: gatewayKey } }
+      : process.env.OPENAI_API_KEY
+        ? { openai: { apiKey: process.env.OPENAI_API_KEY } }
+        : undefined;
+    return new HarnessAgent({
+      harness: createCodex({
+        auth,
+        model: process.env.CODEX_MODEL,
+      }) as unknown as HarnessAgentAdapter,
+      sandbox: sandbox(),
+      instructions: AGENT_INSTRUCTIONS,
+    });
+  }
+
+  if (id === "pi") {
+    // Pi reads gateway credentials from process.env when auth is omitted; we
+    // pass it explicitly when set for parity with the other agents.
+    return new HarnessAgent({
+      harness: createPi({
+        auth: gatewayKey ? { gateway: { apiKey: gatewayKey } } : undefined,
+        model: process.env.PI_MODEL,
+      }) as unknown as HarnessAgentAdapter,
+      sandbox: sandbox(),
+      instructions: AGENT_INSTRUCTIONS,
+    });
+  }
+
+  const auth = gatewayKey
+    ? { gateway: { apiKey: gatewayKey } }
+    : process.env.ANTHROPIC_API_KEY
+      ? { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY } }
+      : undefined;
+  return new HarnessAgent({
+    harness: createClaudeCode({
+      auth,
+      model: process.env.CLAUDE_CODE_MODEL,
+    }) as unknown as HarnessAgentAdapter,
+    sandbox: sandbox(),
+    instructions: AGENT_INSTRUCTIONS,
+  });
+}
+
+// One HarnessAgent instance per agent id, built lazily and reused.
+const agents = new Map<AgentId, HarnessAgent>();
+function getAgent(id: AgentId): HarnessAgent {
+  let agent = agents.get(id);
+  if (!agent) {
+    agent = createAgent(id);
+    agents.set(id, agent);
+  }
+  return agent;
+}
+
+/**
+ * One live harness session per chat. A session owns the sandbox and the
+ * runtime's own conversation history, so follow-up messages in the same chat
+ * keep working against the same workspace. The session is bound to the agent
+ * that created it, so the chosen agent is locked for the life of the chat.
+ *
+ * In-memory only -- fine for a dev-server example. A production app would
+ * persist `session.detach()` state and resume by sessionId instead.
+ */
+type SessionEntry = {
+  session: HarnessAgentSession;
+  agent: HarnessAgent;
+  agentId: AgentId;
+  expireTimer: NodeJS.Timeout;
+};
+
+const sessions = new Map<string, SessionEntry>();
+
+const SESSION_IDLE_MS = 10 * 60 * 1000;
+
+export async function getSession(
+  chatId: string,
+  agentId: AgentId,
+): Promise<SessionEntry> {
+  const existing = sessions.get(chatId);
+  if (existing) {
+    existing.expireTimer.refresh();
+    return existing;
+  }
+
+  const agent = getAgent(agentId);
+  const session = await agent.createSession();
+  const expireTimer = setTimeout(() => {
+    sessions.delete(chatId);
+    session.destroy().catch(() => {});
+  }, SESSION_IDLE_MS);
+  expireTimer.unref?.();
+  const entry: SessionEntry = { session, agent, agentId, expireTimer };
+  sessions.set(chatId, entry);
+  return entry;
+}
+
+export function dropSession(chatId: string): void {
+  const entry = sessions.get(chatId);
+  if (!entry) return;
+  clearTimeout(entry.expireTimer);
+  sessions.delete(chatId);
+  entry.session.destroy().catch(() => {});
+}

+ 21 - 0
examples/harness-chat/lib/agents.ts

@@ -0,0 +1,21 @@
+/**
+ * Agent catalog — client-safe metadata shared by the UI selector and the
+ * server route. Kept free of server-only imports (harness/sandbox SDKs) so it
+ * can be imported from client components without leaking those into the bundle.
+ * The actual harness construction lives in `lib/agent.ts`.
+ */
+export const AGENTS = {
+  "claude-code": { label: "Claude Code" },
+  codex: { label: "Codex" },
+  pi: { label: "Pi" },
+} as const;
+
+export type AgentId = keyof typeof AGENTS;
+
+export const AGENT_IDS = Object.keys(AGENTS) as AgentId[];
+
+export const DEFAULT_AGENT_ID: AgentId = "claude-code";
+
+export function isAgentId(value: unknown): value is AgentId {
+  return typeof value === "string" && value in AGENTS;
+}

+ 226 - 0
examples/harness-chat/lib/render/catalog.ts

@@ -0,0 +1,226 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { z } from "zod";
+
+/**
+ * json-render + HarnessAgent Example Catalog
+ *
+ * Components for a coding agent (Claude Code running in a Vercel Sandbox)
+ * to report its work as structured UI: plans, commands, file changes,
+ * test results, and summaries.
+ */
+export const agentReportCatalog = defineCatalog(schema, {
+  components: {
+    Stack: {
+      props: z.object({
+        direction: z.enum(["horizontal", "vertical"]).nullable(),
+        gap: z.enum(["sm", "md", "lg"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Flex container for laying out children",
+      example: { direction: "vertical", gap: "md" },
+    },
+
+    Grid: {
+      props: z.object({
+        columns: z.enum(["2", "3"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Multi-column grid layout",
+      example: { columns: "2" },
+    },
+
+    Card: {
+      props: z.object({
+        title: z.string().nullable(),
+        description: z.string().nullable(),
+      }),
+      slots: ["default"],
+      description: "Container card grouping related content, never nested",
+      example: { title: "Test results" },
+    },
+
+    Heading: {
+      props: z.object({
+        text: z.string(),
+        level: z.enum(["1", "2", "3"]).nullable(),
+      }),
+      description: "Section heading",
+      example: { text: "What I changed", level: "2" },
+    },
+
+    Text: {
+      props: z.object({
+        content: z.string(),
+        muted: z.boolean().nullable(),
+      }),
+      description: "Paragraph of text",
+      example: { content: "All tests pass after the fix." },
+    },
+
+    Badge: {
+      props: z.object({
+        label: z.string(),
+        tone: z.enum(["neutral", "success", "warning", "error"]).nullable(),
+      }),
+      description: "Small status label",
+      example: { label: "passing", tone: "success" },
+    },
+
+    Callout: {
+      props: z.object({
+        title: z.string().nullable(),
+        content: z.string(),
+        tone: z.enum(["info", "success", "warning", "error"]).nullable(),
+      }),
+      description: "Highlighted note for key takeaways, risks, or follow-ups",
+      example: {
+        title: "Follow-up",
+        content: "Consider adding a regression test for the edge case.",
+        tone: "info",
+      },
+    },
+
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+        detail: z.string().nullable(),
+      }),
+      description: "Key number with a label (files changed, duration, etc.)",
+      example: { label: "Files changed", value: "4", detail: "+120 / -36" },
+    },
+
+    Steps: {
+      props: z.object({
+        items: z.array(
+          z.object({
+            title: z.string(),
+            detail: z.string().nullable(),
+            status: z.enum(["done", "active", "pending", "error"]),
+          }),
+        ),
+      }),
+      description: "Ordered list of work steps with per-step status",
+      example: {
+        items: [
+          { title: "Reproduce the failure", detail: null, status: "done" },
+          { title: "Fix the off-by-one", detail: null, status: "active" },
+        ],
+      },
+    },
+
+    FileChange: {
+      props: z.object({
+        path: z.string(),
+        kind: z.enum(["created", "modified", "deleted"]),
+        summary: z.string().nullable(),
+        additions: z.number().nullable(),
+        deletions: z.number().nullable(),
+      }),
+      description: "One changed file with what was done to it",
+      example: {
+        path: "src/parser.ts",
+        kind: "modified",
+        summary: "Handle empty input in tokenize()",
+        additions: 12,
+        deletions: 3,
+      },
+    },
+
+    CodeBlock: {
+      props: z.object({
+        code: z.string(),
+        language: z.string().nullable(),
+        title: z.string().nullable(),
+      }),
+      description: "Syntax-highlighted code snippet",
+      example: {
+        code: "export const sum = (a: number, b: number) => a + b;",
+        language: "typescript",
+        title: "src/sum.ts",
+      },
+    },
+
+    Terminal: {
+      props: z.object({
+        command: z.string(),
+        output: z.string().nullable(),
+        exitCode: z.number().nullable(),
+      }),
+      description: "A command that was run and its output",
+      example: {
+        command: "pnpm test",
+        output: "12 passed, 0 failed",
+        exitCode: 0,
+      },
+    },
+
+    TestResults: {
+      props: z.object({
+        passed: z.number(),
+        failed: z.number(),
+        skipped: z.number().nullable(),
+        failures: z
+          .array(
+            z.object({
+              name: z.string(),
+              message: z.string(),
+            }),
+          )
+          .nullable(),
+      }),
+      description: "Test run summary with optional failure details",
+      example: { passed: 11, failed: 1, skipped: 0, failures: null },
+    },
+
+    BarChart: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(
+          z.object({
+            label: z.string(),
+            value: z.number(),
+          }),
+        ),
+        unit: z.string().nullable(),
+      }),
+      description:
+        "Bar chart comparing labeled numeric values (e.g. bundle size per module, benchmark per case). Pass already-computed numbers; do not aggregate raw data.",
+      example: {
+        title: "Build time by package",
+        data: [
+          { label: "core", value: 1.2 },
+          { label: "react", value: 2.8 },
+          { label: "cli", value: 0.6 },
+        ],
+        unit: "s",
+      },
+    },
+
+    LineChart: {
+      props: z.object({
+        title: z.string().nullable(),
+        data: z.array(
+          z.object({
+            label: z.string(),
+            value: z.number(),
+          }),
+        ),
+        unit: z.string().nullable(),
+      }),
+      description:
+        "Line chart showing a numeric value as it changes across an ordered sequence (e.g. coverage per commit, latency over runs). Points are connected in array order.",
+      example: {
+        title: "Coverage over commits",
+        data: [
+          { label: "a1b2", value: 71 },
+          { label: "c3d4", value: 78 },
+          { label: "e5f6", value: 84 },
+        ],
+        unit: "%",
+      },
+    },
+  },
+  actions: {},
+});

+ 475 - 0
examples/harness-chat/lib/render/registry.tsx

@@ -0,0 +1,475 @@
+"use client";
+
+import { defineRegistry } from "@json-render/react";
+import {
+  AlertTriangle,
+  Check,
+  CircleDashed,
+  FileMinus,
+  FilePen,
+  FilePlus,
+  Info,
+  Loader2,
+  TriangleAlert,
+  X,
+} from "lucide-react";
+
+import { agentReportCatalog } from "./catalog";
+
+const toneStyles: Record<string, string> = {
+  neutral: "bg-muted text-muted-foreground",
+  success:
+    "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
+  warning: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
+  error: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
+};
+
+const calloutStyles: Record<string, string> = {
+  info: "border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/40",
+  success:
+    "border-emerald-200 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/40",
+  warning:
+    "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
+  error: "border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/40",
+};
+
+const calloutIcons = {
+  info: Info,
+  success: Check,
+  warning: TriangleAlert,
+  error: AlertTriangle,
+} as const;
+
+const stepIcons = {
+  done: <Check className="h-3.5 w-3.5 text-emerald-600" />,
+  active: <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-600" />,
+  pending: <CircleDashed className="h-3.5 w-3.5 text-muted-foreground" />,
+  error: <X className="h-3.5 w-3.5 text-red-600" />,
+} as const;
+
+const fileChangeMeta = {
+  created: { icon: FilePlus, label: "created", className: "text-emerald-600" },
+  modified: { icon: FilePen, label: "modified", className: "text-blue-600" },
+  deleted: { icon: FileMinus, label: "deleted", className: "text-red-600" },
+} as const;
+
+type ChartPoint = { label: string; value: number };
+
+// Charts are intentionally monochrome — they ink in the foreground color.
+const CHART_COLOR = "var(--foreground)";
+
+/** Format a value compactly, appending an optional unit. */
+function formatChartValue(value: number, unit: string | null): string {
+  const rounded =
+    Math.abs(value) >= 100 || Number.isInteger(value)
+      ? Math.round(value).toString()
+      : value.toFixed(1);
+  return unit ? `${rounded}${unit}` : rounded;
+}
+
+function ChartFrame({
+  title,
+  children,
+}: {
+  title: string | null;
+  children: React.ReactNode;
+}) {
+  // No border/background of its own: a chart is content, not a card. This
+  // keeps it from looking like a card nested inside a Card.
+  return (
+    <div>
+      {title && (
+        <div className="mb-2.5 text-xs font-medium text-muted-foreground">
+          {title}
+        </div>
+      )}
+      {children}
+    </div>
+  );
+}
+
+export const { registry } = defineRegistry(agentReportCatalog, {
+  actions: {},
+  components: {
+    Stack: ({ props, children }) => (
+      <div
+        className={`flex ${
+          props.direction === "horizontal"
+            ? "flex-row flex-wrap items-start"
+            : "flex-col"
+        } ${{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"]}`}
+      >
+        {children}
+      </div>
+    ),
+
+    Grid: ({ props, children }) => (
+      <div
+        className={`grid gap-4 ${
+          props.columns === "3" ? "sm:grid-cols-3" : "sm:grid-cols-2"
+        }`}
+      >
+        {children}
+      </div>
+    ),
+
+    Card: ({ props, children }) => (
+      <div className="rounded-2xl border border-border/70 bg-card/80 p-5 shadow-elevated backdrop-blur-sm">
+        {props.title && (
+          <h3 className="text-sm font-semibold tracking-tight mb-1">
+            {props.title}
+          </h3>
+        )}
+        {props.description && (
+          <p className="text-sm text-muted-foreground mb-3">
+            {props.description}
+          </p>
+        )}
+        <div className="flex flex-col gap-3">{children}</div>
+      </div>
+    ),
+
+    Heading: ({ props }) => {
+      const sizes = { "1": "text-xl", "2": "text-lg", "3": "text-base" };
+      return (
+        <div className={`font-semibold ${sizes[props.level ?? "2"]}`}>
+          {props.text}
+        </div>
+      );
+    },
+
+    Text: ({ props }) => (
+      <p
+        className={`text-sm leading-relaxed ${
+          props.muted ? "text-muted-foreground" : ""
+        }`}
+      >
+        {props.content}
+      </p>
+    ),
+
+    Badge: ({ props }) => (
+      <span
+        className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
+          toneStyles[props.tone ?? "neutral"]
+        }`}
+      >
+        {props.label}
+      </span>
+    ),
+
+    Callout: ({ props }) => {
+      const tone = props.tone ?? "info";
+      const Icon = calloutIcons[tone];
+      return (
+        <div className={`rounded-lg border px-3 py-2.5 ${calloutStyles[tone]}`}>
+          <div className="flex gap-2">
+            <Icon className="h-4 w-4 mt-0.5 shrink-0" />
+            <div className="text-sm">
+              {props.title && (
+                <span className="font-medium">{props.title}: </span>
+              )}
+              {props.content}
+            </div>
+          </div>
+        </div>
+      );
+    },
+
+    Metric: ({ props }) => (
+      <div className="rounded-xl border border-border/70 bg-gradient-to-b from-card to-muted/40 px-3.5 py-3">
+        <div className="text-xs font-medium text-muted-foreground">
+          {props.label}
+        </div>
+        <div className="mt-0.5 text-2xl font-semibold tracking-tight tabular-nums">
+          {props.value}
+        </div>
+        {props.detail && (
+          <div className="text-xs text-muted-foreground tabular-nums">
+            {props.detail}
+          </div>
+        )}
+      </div>
+    ),
+
+    Steps: ({ props }) => (
+      <ol className="flex flex-col gap-2">
+        {props.items.map((item, i) => (
+          <li key={i} className="flex items-start gap-2.5 text-sm">
+            <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border bg-card">
+              {stepIcons[item.status]}
+            </span>
+            <span>
+              <span
+                className={
+                  item.status === "pending" ? "text-muted-foreground" : ""
+                }
+              >
+                {item.title}
+              </span>
+              {item.detail && (
+                <span className="block text-xs text-muted-foreground">
+                  {item.detail}
+                </span>
+              )}
+            </span>
+          </li>
+        ))}
+      </ol>
+    ),
+
+    FileChange: ({ props }) => {
+      const meta = fileChangeMeta[props.kind];
+      const Icon = meta.icon;
+      return (
+        <div className="flex items-start gap-2.5 rounded-lg border bg-card px-3 py-2">
+          <Icon className={`h-4 w-4 mt-0.5 shrink-0 ${meta.className}`} />
+          <div className="min-w-0 text-sm">
+            <div className="flex flex-wrap items-center gap-2">
+              <code className="font-mono text-xs">{props.path}</code>
+              <span className={`text-xs ${meta.className}`}>{meta.label}</span>
+              {(props.additions != null || props.deletions != null) && (
+                <span className="text-xs tabular-nums">
+                  {props.additions != null && (
+                    <span className="text-emerald-600">
+                      +{props.additions}{" "}
+                    </span>
+                  )}
+                  {props.deletions != null && (
+                    <span className="text-red-600">-{props.deletions}</span>
+                  )}
+                </span>
+              )}
+            </div>
+            {props.summary && (
+              <div className="text-xs text-muted-foreground">
+                {props.summary}
+              </div>
+            )}
+          </div>
+        </div>
+      );
+    },
+
+    CodeBlock: ({ props }) => (
+      <div className="overflow-hidden rounded-lg border">
+        {props.title && (
+          <div className="border-b bg-muted/50 px-3 py-1.5 font-mono text-xs text-muted-foreground">
+            {props.title}
+          </div>
+        )}
+        <pre className="overflow-x-auto bg-card p-3 text-xs leading-relaxed">
+          <code>{props.code}</code>
+        </pre>
+      </div>
+    ),
+
+    Terminal: ({ props }) => (
+      <div className="overflow-hidden rounded-lg bg-zinc-950 text-zinc-100">
+        <div className="flex items-center justify-between gap-2 border-b border-zinc-800 px-3 py-1.5">
+          <code className="font-mono text-xs text-zinc-300">
+            $ {props.command}
+          </code>
+          {props.exitCode != null && (
+            <span
+              className={`text-xs tabular-nums ${
+                props.exitCode === 0 ? "text-emerald-400" : "text-red-400"
+              }`}
+            >
+              exit {props.exitCode}
+            </span>
+          )}
+        </div>
+        {props.output && (
+          <pre className="max-h-64 overflow-auto p-3 font-mono text-xs leading-relaxed text-zinc-300 whitespace-pre-wrap">
+            {props.output}
+          </pre>
+        )}
+      </div>
+    ),
+
+    TestResults: ({ props }) => (
+      <div className="flex flex-col gap-2">
+        <div className="flex gap-2">
+          <span
+            className={`rounded-md px-2 py-1 text-xs ${toneStyles.success}`}
+          >
+            {props.passed} passed
+          </span>
+          <span
+            className={`rounded-md px-2 py-1 text-xs ${
+              props.failed > 0 ? toneStyles.error : toneStyles.neutral
+            }`}
+          >
+            {props.failed} failed
+          </span>
+          {props.skipped != null && props.skipped > 0 && (
+            <span
+              className={`rounded-md px-2 py-1 text-xs ${toneStyles.warning}`}
+            >
+              {props.skipped} skipped
+            </span>
+          )}
+        </div>
+        {props.failures && props.failures.length > 0 && (
+          <ul className="flex flex-col gap-1.5">
+            {props.failures.map((f, i) => (
+              <li
+                key={i}
+                className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs dark:border-red-900 dark:bg-red-950/40"
+              >
+                <div className="font-mono font-medium">{f.name}</div>
+                <div className="text-muted-foreground">{f.message}</div>
+              </li>
+            ))}
+          </ul>
+        )}
+      </div>
+    ),
+
+    BarChart: ({ props }) => {
+      const data = props.data as ChartPoint[];
+      const color = CHART_COLOR;
+      const values = data.map((d) => d.value);
+      const max = Math.max(...values, 0);
+      const min = Math.min(...values, 0);
+      const span = max - min || 1;
+      const zeroTop = ((max - 0) / span) * 100;
+
+      return (
+        <ChartFrame title={props.title}>
+          {data.length === 0 ? (
+            <div className="text-xs text-muted-foreground">No data</div>
+          ) : (
+            <div className="flex h-40 gap-2.5">
+              {data.map((d, i) => {
+                const rawHeight = (Math.abs(d.value) / span) * 100;
+                const availableHeight = d.value >= 0 ? zeroTop : 100 - zeroTop;
+                const height =
+                  d.value === 0
+                    ? 0
+                    : Math.min(Math.max(rawHeight, 1.5), availableHeight);
+                const top = d.value >= 0 ? zeroTop - height : zeroTop;
+                return (
+                  <div
+                    key={i}
+                    className="flex h-full min-w-0 flex-1 flex-col items-center gap-1.5"
+                  >
+                    <div className="text-[11px] tabular-nums text-muted-foreground">
+                      {formatChartValue(d.value, props.unit)}
+                    </div>
+                    <div className="relative min-h-0 w-full flex-1">
+                      <div
+                        className="absolute inset-x-0 border-t border-muted-foreground/25"
+                        style={{ top: `${zeroTop}%` }}
+                      />
+                      {d.value === 0 ? (
+                        <div
+                          className="absolute inset-x-0 h-px"
+                          style={{
+                            top: `${zeroTop}%`,
+                            backgroundColor: color,
+                          }}
+                        />
+                      ) : (
+                        <div
+                          className="absolute inset-x-0 rounded-sm"
+                          style={{
+                            top: `${top}%`,
+                            height: `${height}%`,
+                            backgroundColor: color,
+                          }}
+                        />
+                      )}
+                    </div>
+                    <div className="w-full truncate text-center text-[11px] text-muted-foreground">
+                      {d.label}
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </ChartFrame>
+      );
+    },
+
+    LineChart: ({ props }) => {
+      const data = props.data as ChartPoint[];
+      const color = CHART_COLOR;
+
+      const W = 100;
+      const H = 40;
+      const values = data.map((d) => d.value);
+      const max = Math.max(...values, 0);
+      const min = Math.min(...values, 0);
+      const span = max - min || 1;
+
+      // Map each point into the viewBox; single point sits centered.
+      const points = data.map((d, i) => {
+        const x = data.length === 1 ? W / 2 : (i / (data.length - 1)) * W;
+        const y = H - ((d.value - min) / span) * H;
+        return { x, y };
+      });
+      const line = points.map((p) => `${p.x},${p.y}`).join(" ");
+      const area = `0,${H} ${line} ${W},${H}`;
+
+      const first = data[0];
+      const last = data[data.length - 1];
+
+      return (
+        <ChartFrame title={props.title}>
+          {!first || !last ? (
+            <div className="text-xs text-muted-foreground">No data</div>
+          ) : (
+            <>
+              <svg
+                viewBox={`0 0 ${W} ${H}`}
+                preserveAspectRatio="none"
+                className="h-28 w-full"
+                role="img"
+              >
+                <polygon points={area} fill={color} fillOpacity={0.06} />
+                <polyline
+                  points={line}
+                  fill="none"
+                  stroke={color}
+                  strokeWidth={1.5}
+                  strokeLinejoin="round"
+                  strokeLinecap="round"
+                  vectorEffect="non-scaling-stroke"
+                />
+              </svg>
+              <div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
+                <span className="truncate">
+                  {first.label}
+                  <span className="tabular-nums">
+                    {" "}
+                    · {formatChartValue(first.value, props.unit)}
+                  </span>
+                </span>
+                {data.length > 1 && (
+                  <span className="truncate">
+                    {last.label}
+                    <span className="tabular-nums">
+                      {" "}
+                      · {formatChartValue(last.value, props.unit)}
+                    </span>
+                  </span>
+                )}
+              </div>
+            </>
+          )}
+        </ChartFrame>
+      );
+    },
+  },
+});
+
+export function Fallback({ type }: { type: string }) {
+  return (
+    <div className="rounded-lg border border-dashed px-3 py-2 text-xs text-muted-foreground">
+      Unknown component: {type}
+    </div>
+  );
+}

+ 42 - 0
examples/harness-chat/lib/render/renderer.tsx

@@ -0,0 +1,42 @@
+"use client";
+
+import { type ReactNode } from "react";
+import {
+  Renderer,
+  type ComponentRenderer,
+  type Spec,
+  StateProvider,
+  VisibilityProvider,
+  ActionProvider,
+} from "@json-render/react";
+
+import { registry, Fallback } from "./registry";
+
+const fallback: ComponentRenderer = ({ element }) => (
+  <Fallback type={element.type} />
+);
+
+export function ReportRenderer({
+  spec,
+  loading,
+}: {
+  spec: Spec | null;
+  loading?: boolean;
+}): ReactNode {
+  if (!spec) return null;
+
+  return (
+    <StateProvider initialState={spec.state ?? {}}>
+      <VisibilityProvider>
+        <ActionProvider>
+          <Renderer
+            spec={spec}
+            registry={registry}
+            fallback={fallback}
+            loading={loading}
+          />
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}

+ 20 - 0
examples/harness-chat/next.config.ts

@@ -0,0 +1,20 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  // The dev server is reached through the portless proxy origin; Next 16
+  // blocks cross-origin dev requests (and silently breaks hydration)
+  // unless the origin is allowlisted.
+  allowedDevOrigins: ["harness-chat-demo.json-render.localhost"],
+  // The harness adapters ship sandbox bridge files they load at runtime via
+  // new URL(..., import.meta.url); bundling breaks that resolution.
+  serverExternalPackages: [
+    "@ai-sdk/harness",
+    "@ai-sdk/harness-claude-code",
+    "@ai-sdk/harness-codex",
+    "@ai-sdk/harness-pi",
+    "@ai-sdk/sandbox-vercel",
+    "@vercel/sandbox",
+  ],
+};
+
+export default nextConfig;

+ 44 - 0
examples/harness-chat/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "example-harness-chat",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+    "dev": "portless harness-chat-demo.json-render next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/harness": "1.0.0-canary.9",
+    "@ai-sdk/harness-claude-code": "1.0.0-canary.5",
+    "@ai-sdk/harness-codex": "1.0.0-canary.5",
+    "@ai-sdk/harness-pi": "1.0.0-canary.5",
+    "@ai-sdk/react": "4.0.0-canary.176",
+    "@ai-sdk/sandbox-vercel": "1.0.0-canary.9",
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@streamdown/code": "^1.1.1",
+    "ai": "7.0.0-canary.173",
+    "lucide-react": "^0.563.0",
+    "next": "16.2.9",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "streamdown": "^2.5.0",
+    "zod": "4.3.6"
+  },
+  "devDependencies": {
+    "@internal/eslint-config": "workspace:*",
+    "@tailwindcss/postcss": "^4.1.18",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "eslint": "^9.39.1",
+    "postcss": "^8.5.6",
+    "tailwindcss": "^4.1.18",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 5 - 0
examples/harness-chat/postcss.config.mjs

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};

+ 13 - 0
examples/harness-chat/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

File diff suppressed because it is too large
+ 330 - 106
pnpm-lock.yaml


+ 2 - 0
pnpm-workspace.yaml

@@ -1,10 +1,12 @@
 minimumReleaseAge: 2880
 engineStrict: true
 allowBuilds:
+  '@google/genai': false
   "@mongodb-js/zstd": false
   esbuild: false
   msw: false
   node-liblzma: false
+  protobufjs: false
   sharp: false
 
 packages:

+ 1 - 1
turbo.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://turborepo.dev/schema.json",
   "ui": "tui",
-  "globalEnv": ["AI_GATEWAY_API_KEY", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
+  "globalEnv": ["AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_MODEL", "OPENAI_API_KEY", "CODEX_MODEL", "PI_MODEL", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
   "tasks": {
     "build": {
       "dependsOn": ["^build"],

Some files were not shown because too many files changed in this diff