From 27ac0a357a4075691e1ebedf84be5e5e7932d61c Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 21 Apr 2026 10:32:17 -0700 Subject: [PATCH 1/2] Add TanStack RSC and AI Code Mode skills Adds three new Claude skills for the tanstack-start-react framework: - tanstack-rsc: enabling React Server Components via @vitejs/plugin-rsc, including Node-only dependency externalization guidance. - tanstack-ai-code-mode: setting up @tanstack/ai-code-mode for sandboxed AI-authored tool execution. - tanstack-ai-code-mode-ui: building the UI layer on top of ai-code-mode. Made-with: Cursor --- .../skills/tanstack-ai-code-mode-ui/SKILL.md | 702 ++++++++++++++++++ .../skills/tanstack-ai-code-mode/SKILL.md | 277 +++++++ .../.claude/skills/tanstack-rsc/SKILL.md | 213 ++++++ 3 files changed, 1192 insertions(+) create mode 100644 frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md create mode 100644 frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md create mode 100644 frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md diff --git a/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md new file mode 100644 index 0000000..b8a108e --- /dev/null +++ b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md @@ -0,0 +1,702 @@ +--- +name: tanstack-ai-code-mode-ui +description: Architect a code mode setup where UI generation is driven by the LLM through injected UI bindings. Use when designing a system where the model builds interfaces (dashboards, forms, visualizations, canvases, etc.) by calling UI functions from inside the sandbox, rather than returning structured UI payloads as tool results. +--- + +# Generating UI from Code Mode + +This skill covers a pattern for letting an LLM build interactive UI by writing code inside a code mode sandbox, where UI operations are exposed as `external_*` binding functions. The LLM writes one TypeScript program that mixes data fetching, computation, and UI construction — each UI call emits a custom event that the client turns into a node in a rendered component tree. + +Use this pattern when you want the model to drive the UI, not just return data. Examples: analytic dashboards, report canvases, form wizards, document generators, diagram builders, configurators, admin tools. + +> Prerequisite: this skill assumes code mode is already set up. See `tanstack-ai-code-mode` for the base setup (drivers, `createCodeMode`, API route, client wiring). + +## Why Inject UI Through Code Mode + +Alternatives and why they are usually worse: + +- **A tool that returns a structured UI payload** — forces the LLM to construct a large JSON tree in one shot, inflating tokens and making iterative composition hard. +- **Many small UI tools called individually by the LLM** — one round-trip per component, many LLM turns, slow and expensive. +- **UI bindings inside code mode** — the LLM writes ordinary loops/conditionals that build the tree; data fetching and UI construction interleave in a single program; only one LLM turn is needed to produce a full interface. + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ LLM │ +│ emits: execute_typescript({ typescriptCode: "..." }) │ +└────────────────────────┬─────────────────────────────────────────┘ + │ +┌────────────────────────▼─────────────────────────────────────────┐ +│ Sandbox (Node isolate or QuickJS) │ +│ user program can call: │ +│ external_(...) ← from code mode `tools` │ +│ external_(...) ← from `getSkillBindings` │ +│ │ +│ each external_ call: │ +│ 1. validates input │ +│ 2. calls emitCustomEvent('ui:', payload) │ +│ 3. optionally updates server-side state │ +└────────────────────────┬─────────────────────────────────────────┘ + │ SSE custom events +┌────────────────────────▼─────────────────────────────────────────┐ +│ Client (useChat → onCustomEvent) │ +│ receives ui: events │ +│ dispatches into a local tree store │ +│ renders the tree through a NodeRenderer │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Three pieces you design: + +1. A set of **UI bindings** — one function per component type you want the LLM to produce. +2. A **custom event protocol** — the shape of payloads emitted by the bindings. +3. A **client renderer** — receives events, maintains a tree, and renders each node with a React component. + +## 1. Designing UI Bindings + +Bindings are the functions the LLM will call from inside the sandbox. Each one should: + +- Validate its input with a Zod schema (the sandbox calls it with whatever the model wrote). +- Produce a small, declarative event describing what to do to the UI tree. +- Emit that event via the context's `emitCustomEvent`. +- Optionally mirror the change into server-side state (useful if the server later needs to render, replay, or inspect the tree). + +A binding implements the `ToolBinding` interface from `@tanstack/ai-code-mode`: + +```typescript +import { z } from 'zod' +import { convertSchemaToJsonSchema } from '@tanstack/ai' +import type { ToolBinding } from '@tanstack/ai-code-mode' + +const cardBindingSchema = z.object({ + id: z.string().describe('Unique ID for this component'), + parentId: z.string().optional().describe('Parent container ID'), + title: z.string().optional(), +}) + +const cardBinding: ToolBinding = { + name: 'external_ui_card', + description: 'Create a card container with optional title', + inputSchema: convertSchemaToJsonSchema(cardBindingSchema) || { + type: 'object', + properties: {}, + }, + outputSchema: convertSchemaToJsonSchema(z.object({ success: z.boolean() })), + execute: async (args, context) => { + const input = cardBindingSchema.parse(args) + + context?.emitCustomEvent?.('ui:node', { + op: 'add', + id: input.id, + type: 'card', + parentId: input.parentId, + props: { title: input.title }, + }) + + return { success: true } + }, +} +``` + +A helper like `createUIBinding(name, description, schema, toEvent)` is worth writing once you have more than a handful of bindings — most of them follow the same pattern. + +### Inject Bindings via `getSkillBindings` + +Pass your bindings into `createCodeMode` so they become available as `external_*` inside the sandbox: + +```typescript +import { createCodeMode } from '@tanstack/ai-code-mode' + +const { tool, systemPrompt } = createCodeMode({ + driver, + tools: dataTools, // regular server tools (DB, APIs, etc.) + timeout: 60000, + memoryLimit: 128, + getSkillBindings: async () => ({ + external_ui_card: cardBinding, + external_ui_text: textBinding, + external_ui_chart: chartBinding, + // ... + }), +}) +``` + +`getSkillBindings` is called per-request, so you can return different bindings for different users, sessions, or feature flags. + +## 2. Designing the Event Protocol + +A tree-shaped UI needs four operations. Define them once and every binding emits one of these shapes: + +```typescript +type UIEvent = + | { op: 'add'; id: string; type: string; parentId?: string; props: Record } + | { op: 'update'; id: string; props: Record } + | { op: 'remove'; id: string } + | { op: 'reorder'; parentId?: string; childIds: string[] } +``` + +Corresponding bindings: + +- `external_ui_(...)` — emits `add` events, one per component type. +- `external_ui_update({ id, props })` — emits `update`, for mutating existing nodes. +- `external_ui_remove({ id })` — emits `remove`. +- `external_ui_reorder({ parentId, childIds })` — emits `reorder`. + +Keep the event shapes flat and serializable — they travel over SSE. + +### Component Categories to Consider + +A useful binding set typically includes components across these categories. Adjust to your domain. + +| Category | Purpose | Examples | +|----------|---------|----------| +| **Layout** | Group/position children | vbox, hbox, grid, card, section, tabs | +| **Content** | Leaf text/visual | text (h1/h2/body/caption), markdown, badge, divider, spacer, image | +| **Data** | Display datasets | chart (line/bar/area/pie), dataTable, sparkline, metric, progress | +| **Input** | Interactive controls | button, input, select, slider, toggle | +| **Status** | Feedback states | placeholder/skeleton, error, empty, toast | + +For each binding, give the model enough enum variants (`variant: 'default' | 'outlined' | 'elevated'`, `gap: 'sm' | 'md' | 'lg'`, etc.) to cover realistic layouts, but keep the surface small enough that it fits in a system prompt. + +Tip: use `.catch('md')` on enum schemas so an invalid value from the model gracefully falls back to a sensible default rather than failing the whole program. + +### IDs, Parents, and Auto-Generation + +Two conventions that work well: + +- **Container components require an explicit `id`** (the model will reference it as `parentId` of children). +- **Leaf components make `id` optional** and auto-generate one when omitted. This lets the model write terse code when it doesn't need to update that node later. + +```typescript +function generateId(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2, 10)}` +} +``` + +## 3. The Client Renderer + +### Receiving Events + +The `useChat` hook exposes an `onCustomEvent` callback: + +```typescript +const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + onCustomEvent: (eventType, data) => { + if (eventType === 'ui:node') { + dispatchUIEvent(data as UIEvent) + } + }, +}) +``` + +### Tree State + +Maintain a map of nodes plus an ordered list of root IDs: + +```typescript +interface UINode { + id: string + type: string + props: Record + children: string[] +} + +interface UITree { + nodes: Map + rootIds: string[] +} +``` + +A reducer applies `UIEvent`s: + +- `add` — insert into `nodes`, append `id` to parent's `children` (or `rootIds` if no `parentId`). +- `update` — merge `props` into existing node. +- `remove` — delete node and its descendants, remove from parent. +- `reorder` — replace `children` (or `rootIds`) with the new `childIds` list. + +### Node Renderer + +Map each `type` string to a React component. Recurse through `children`: + +```typescript +const componentMap: Record> = { + vbox: VBox, + hbox: HBox, + card: Card, + text: Text, + chart: Chart, + dataTable: DataTable, + // ... +} + +function NodeRenderer({ node, nodes }: { node: UINode; nodes: Map }) { + const Component = componentMap[node.type] + if (!Component) return null + + const children = node.children.map((childId) => { + const child = nodes.get(childId) + return child ? : null + }) + + return {children} +} +``` + +Wrap each node in `motion.div` with `AnimatePresence` to get smooth add/remove animations as the model builds the UI incrementally. + +## 4. Teach the Model the API + +Put a system prompt alongside the code mode `systemPrompt` that lists every binding, its parameters, and one or two worked examples. The model can't discover the API at runtime — the prompt is the documentation. + +A good shape for this prompt: + +1. One sentence describing what the UI system does. +2. Listing of functions grouped by category, each with parameters and a short description. +3. One full example that fetches data, aggregates it, and builds a small UI. +4. A short list of best practices (create containers before children, keep trees focused, use meaningful IDs, interleave data fetching and UI calls). + +Pass it as a third entry in `systemPrompts`: + +```typescript +const stream = chat({ + adapter, + messages, + tools: [codeModeTool, ...managementTools], + systemPrompts: [ + DOMAIN_SYSTEM_PROMPT, // "You are an analyst for X" + codeModeSystemPrompt, // from createCodeMode — teaches the sandbox + UI_SYSTEM_PROMPT, // your binding API reference + ], + agentLoopStrategy: maxIterations(20), + abortController, +}) +``` + +## 5. End-to-End: A Single Component + +Here is one component — `metric` (a big number with a label) — implemented all the way from binding to render. Every other component in the system follows the same shape. + +### 5.1 Binding (server) + +```typescript +// src/lib/ui/bindings.ts +import { z } from 'zod' +import { convertSchemaToJsonSchema } from '@tanstack/ai' +import type { ToolBinding } from '@tanstack/ai-code-mode' + +function generateId(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2, 10)}` +} + +const metricSchema = z.object({ + id: z + .string() + .optional() + .describe('Component ID (auto-generated if omitted)'), + parentId: z + .string() + .optional() + .describe('Parent container ID (root if omitted)'), + value: z.union([z.number(), z.string()]).describe('The metric value'), + label: z.string().describe('Label describing the metric'), + format: z + .enum(['number', 'currency', 'percent', 'compact']) + .catch('number') + .describe('Number format'), + prefix: z.string().optional().describe('Prefix (e.g., "$")'), + suffix: z.string().optional().describe('Suffix (e.g., "/week")'), + variant: z + .enum(['default', 'success', 'warning', 'error']) + .catch('default') + .describe('Color variant'), +}) + +export const metricBinding: ToolBinding = { + name: 'external_ui_metric', + description: 'Display a big number with a label', + inputSchema: convertSchemaToJsonSchema(metricSchema) || { + type: 'object', + properties: {}, + }, + outputSchema: convertSchemaToJsonSchema(z.object({ success: z.boolean() })), + execute: async (args, context) => { + const input = metricSchema.parse(args) + const id = input.id || generateId('metric') + + context?.emitCustomEvent?.('ui:node', { + op: 'add', + id, + type: 'metric', + parentId: input.parentId, + props: { + value: input.value, + label: input.label, + format: input.format, + prefix: input.prefix, + suffix: input.suffix, + variant: input.variant, + }, + }) + + return { success: true, id } + }, +} +``` + +### 5.2 Inject the Binding (server) + +```typescript +// src/routes/api.chat.ts (excerpt) +import { createCodeMode } from '@tanstack/ai-code-mode' +import { metricBinding } from '#/lib/ui/bindings' + +const { tool, systemPrompt } = createCodeMode({ + driver, + tools: dataTools, + timeout: 60000, + memoryLimit: 128, + getSkillBindings: async () => ({ + external_ui_metric: metricBinding, + // ...other bindings + }), +}) +``` + +### 5.3 System Prompt Entry + +This is what you include in the `UI_SYSTEM_PROMPT` so the model knows the function exists: + +``` +external_ui_metric({ id?, parentId?, value, label, format?, prefix?, suffix?, variant? }) + Display a big number with a label. + - value: number | string — the number to display + - label: string — description shown below the number + - format: 'number' | 'currency' | 'percent' | 'compact' (default: 'number') + - prefix: string — e.g. '$' + - suffix: string — e.g. '/week' + - variant: 'default' | 'success' | 'warning' | 'error' (default: 'default') +``` + +### 5.4 Client Event Handler + +```typescript +// src/routes/chat.tsx (excerpt) +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { useUITree } from '#/lib/ui/use-ui-tree' + +function ChatPage() { + const { tree, dispatchUIEvent } = useUITree() + + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + onCustomEvent: (eventType, data) => { + if (eventType === 'ui:node') { + dispatchUIEvent(data as UIEvent) + } + }, + }) + + return ( +
+ + +
+ ) +} +``` + +### 5.5 Tree Reducer (client) + +```typescript +// src/lib/ui/use-ui-tree.ts +import { useCallback, useState } from 'react' + +export interface UINode { + id: string + type: string + props: Record + children: string[] +} + +interface UITree { + nodes: Map + rootIds: string[] +} + +export function useUITree() { + const [tree, setTree] = useState({ + nodes: new Map(), + rootIds: [], + }) + + const dispatchUIEvent = useCallback((event: UIEvent) => { + setTree((prev) => { + const nodes = new Map(prev.nodes) + let rootIds = prev.rootIds + + if (event.op === 'add') { + nodes.set(event.id, { + id: event.id, + type: event.type, + props: event.props, + children: [], + }) + if (event.parentId) { + const parent = nodes.get(event.parentId) + if (parent) { + nodes.set(event.parentId, { + ...parent, + children: [...parent.children, event.id], + }) + } + } else { + rootIds = [...rootIds, event.id] + } + } else if (event.op === 'update') { + const node = nodes.get(event.id) + if (node) { + nodes.set(event.id, { + ...node, + props: { ...node.props, ...event.props }, + }) + } + } else if (event.op === 'remove') { + nodes.delete(event.id) + rootIds = rootIds.filter((id) => id !== event.id) + for (const [id, n] of nodes) { + if (n.children.includes(event.id)) { + nodes.set(id, { + ...n, + children: n.children.filter((c) => c !== event.id), + }) + } + } + } else if (event.op === 'reorder') { + if (event.parentId) { + const parent = nodes.get(event.parentId) + if (parent) { + nodes.set(event.parentId, { ...parent, children: event.childIds }) + } + } else { + rootIds = event.childIds + } + } + + return { nodes, rootIds } + }) + }, []) + + return { tree, dispatchUIEvent } +} +``` + +### 5.6 React Primitive + +```typescript +// src/components/ui/Metric.tsx +interface MetricProps { + id?: string + value: number | string + label: string + format?: 'number' | 'currency' | 'percent' | 'compact' + prefix?: string + suffix?: string + variant?: 'default' | 'success' | 'warning' | 'error' +} + +const variantClasses = { + default: 'text-gray-900', + success: 'text-green-600', + warning: 'text-amber-600', + error: 'text-red-600', +} + +function formatValue( + value: number | string, + format: MetricProps['format'], +): string { + if (typeof value === 'string') return value + switch (format) { + case 'currency': + return value.toLocaleString('en-US', { maximumFractionDigits: 2 }) + case 'percent': + return `${(value * 100).toFixed(1)}%` + case 'compact': + return Intl.NumberFormat('en', { notation: 'compact' }).format(value) + default: + return value.toLocaleString('en-US') + } +} + +export function Metric({ + value, + label, + format = 'number', + prefix, + suffix, + variant = 'default', +}: MetricProps) { + return ( +
+
+ {prefix} + {formatValue(value, format)} + {suffix} +
+
{label}
+
+ ) +} +``` + +### 5.7 Wire Into the NodeRenderer + +```typescript +// src/components/ui/NodeRenderer.tsx +import { Metric } from './Metric' +import type { UINode } from '#/lib/ui/use-ui-tree' + +const componentMap: Record> = { + metric: Metric, + // vbox, hbox, card, chart, ... +} + +export function NodeRenderer({ + node, + nodes, +}: { + node: UINode + nodes: Map +}) { + const Component = componentMap[node.type] + if (!Component) return null + + const children = node.children.map((childId) => { + const child = nodes.get(childId) + return child ? : null + }) + + return ( + + {children.length > 0 ? children : undefined} + + ) +} + +export function UIRenderer({ + nodes, + rootIds, +}: { + nodes: Map + rootIds: string[] +}) { + return ( +
+ {rootIds.map((id) => { + const node = nodes.get(id) + return node ? : null + })} +
+ ) +} +``` + +### 5.8 What the Model Writes + +With the binding injected and the system prompt in place, the model produces code like this inside `execute_typescript`: + +```typescript +const { rows } = await external_queryTable({ table: 'purchases' }) + +external_ui_metric({ + value: rows.reduce((s, r) => s + r.total, 0), + label: 'Total Revenue', + format: 'currency', + prefix: '$', +}) + +return { rowCount: rows.length } +``` + +When that runs: + +1. `external_queryTable` hits the server tool, returns data into the sandbox. +2. `external_ui_metric` is called — the binding validates the input, generates an id, and emits a `ui:node` event with `op: 'add'`. +3. The event travels over SSE to the client. +4. `useChat`'s `onCustomEvent` forwards it to `dispatchUIEvent`. +5. The reducer inserts a new `UINode` with `type: 'metric'`. +6. `UIRenderer` re-renders, `NodeRenderer` looks up `'metric'` in `componentMap`, and the `Metric` component appears on screen. + +To add another component type (say `text`), repeat the same seven steps: schema → binding → prompt entry → React primitive → `componentMap` entry. The event protocol and the tree reducer stay the same. + +## 6. Generic Multi-Component Example + +The pattern scales naturally. The model writes a program like this inside `execute_typescript`: + +```typescript +// Fetch data using normal data tools +const { rows } = await external_queryTable({ table: 'purchases' }) + +// Summary strip at the top +external_ui_hbox({ id: 'summary', gap: 'lg' }) +external_ui_metric({ + parentId: 'summary', + value: rows.length, + label: 'Total Purchases', +}) +external_ui_metric({ + parentId: 'summary', + value: rows.reduce((s, r) => s + r.total, 0), + label: 'Revenue', + format: 'currency', + prefix: '$', +}) + +// Chart below it +const byDay: Record = {} +for (const r of rows) { + byDay[r.purchased_at] = (byDay[r.purchased_at] || 0) + r.total +} +external_ui_chart({ + id: 'trend', + type: 'line', + data: Object.entries(byDay).map(([date, total]) => ({ date, total })), + xKey: 'date', + yKey: 'total', +}) + +return { rowsAnalyzed: rows.length } +``` + +Notice: one LLM turn, mixed data fetching and UI building, no hand-built JSON tree. The code is straightforward because the bindings are. + +## Optional: Server-Side Mirror of the Tree + +Some systems want the server to know the current tree as well — for persistence, replay, multi-client sync, or handling dependent tools. A common pattern: + +- Each binding calls `emitCustomEvent(...)` AND a server-side `applyEvent(treeId, event)` function that updates an in-memory store keyed by some `treeId`. +- The `treeId` is a parameter on every binding (e.g. `reportId`, `canvasId`, `documentId`). +- A management tool like `new_({ id, title })` creates a tree up front and auto-selects it on the client via its own custom event (`ui:created`). + +This is only necessary if something outside the sandbox needs to read the tree. If the tree only lives in one browser session, skip it and keep the bindings pure event emitters. + +## Advanced: Handlers and Reactive Bindings + +Two optional features that extend the pattern: + +- **Handlers** — let a binding accept TypeScript strings for event handlers (e.g. `onPress`). Validate them on the server against an allowlist of bindings they can call, then ship them to the client for execution when the event fires. +- **Subscriptions / signals** — let components subscribe to named signals (`subscriptions: ['balances']`) with a `dataSource` string that recomputes props when the signal changes. Requires a signal registry and an invalidation endpoint. + +These add significant complexity (code validation, execution, invalidation plumbing). Only add them if your UI genuinely needs interactivity beyond what a rebuild-on-demand model provides. + +## Best Practices + +- **One binding per component type.** Don't build a single `external_ui_component({ type, ... })` god-binding — schemas per component give the model better constraints and better error messages. +- **Schemas are your contract with the model.** Use `z.enum([...]).catch(default)` generously, `.describe(...)` everything, and mark optional things optional. +- **Keep bindings pure emit + optional state update.** No side effects, no network calls inside a binding — that's what data tools are for. +- **Document everything in the system prompt.** The model only knows what you tell it. +- **Design events to be idempotent where possible** — makes reconnection, replay, and multi-client sync easier later. +- **Prefer declarative operations** (`add`, `update`, `remove`, `reorder`) over imperative ones (`moveLeft`, `bringToFront`) — simpler protocol, easier client state. +- **Start small.** A dozen well-chosen bindings covering layout + a few content + one or two data components is usually enough to produce surprisingly rich UIs. diff --git a/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md new file mode 100644 index 0000000..82496a6 --- /dev/null +++ b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md @@ -0,0 +1,277 @@ +--- +name: tanstack-ai-code-mode +description: Set up and use TanStack AI Code Mode with sandboxed TypeScript execution. Use when adding code mode to a TanStack Start project, configuring isolate drivers, defining server tools, or wiring up the code mode chat API endpoint. +--- + +# TanStack AI Code Mode Setup + +Code Mode is a pattern from `@tanstack/ai-code-mode` where the LLM writes a single TypeScript program that calls your server tools as `external_*` functions inside a sandboxed VM, instead of making many individual tool calls. This reduces LLM round-trips, saves tokens, and produces faster responses. + +## Prerequisites + +- **Node.js 24** — required for `isolated-vm` (the native V8 sandbox). Falls back to QuickJS on other versions. +- At least one AI provider key: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` in `.env.local`. + +## Core Packages + +``` +@tanstack/ai # chat(), toolDefinition(), toServerSentEventsStream() +@tanstack/ai-code-mode # createCodeMode() — produces tool + systemPrompt +@tanstack/ai-isolate-node # V8 isolate sandbox driver (requires Node 24) +@tanstack/ai-isolate-quickjs # QuickJS fallback sandbox driver +``` + +Provider adapters (pick at least one): + +``` +@tanstack/ai-anthropic +@tanstack/ai-openai +@tanstack/ai-gemini +``` + +Add `isolated-vm` to `pnpm.onlyBuiltDependencies` in `package.json` so the native addon compiles: + +```json +{ + "pnpm": { + "onlyBuiltDependencies": ["esbuild", "isolated-vm", "lightningcss"] + } +} +``` + +## Step 1: Create the Isolate Driver + +Create a factory that tries the Node isolate first, falling back to QuickJS: + +```typescript +// src/lib/create-isolate-driver.ts +import type { IsolateDriver } from '@tanstack/ai-code-mode' + +export type IsolateVM = 'node' | 'quickjs' + +const driverCache = new Map() + +export async function createIsolateDriver( + vm: IsolateVM = 'node', +): Promise { + const cached = driverCache.get(vm) + if (cached) return cached + + let driver: IsolateDriver + + switch (vm) { + case 'quickjs': { + const { createQuickJSIsolateDriver } = + await import('@tanstack/ai-isolate-quickjs') + driver = createQuickJSIsolateDriver() + break + } + case 'node': + default: { + try { + const { createNodeIsolateDriver } = + await import('@tanstack/ai-isolate-node') + driver = createNodeIsolateDriver() + } catch { + const { createQuickJSIsolateDriver } = + await import('@tanstack/ai-isolate-quickjs') + driver = createQuickJSIsolateDriver() + } + break + } + } + + driverCache.set(vm, driver) + return driver +} +``` + +Cache the driver — creating it is expensive (VM startup). + +## Step 2: Define Server Tools + +Tools are defined with `toolDefinition()` from `@tanstack/ai`. Each tool has a name, description, Zod input/output schemas, and a `.server()` handler. Inside the code mode sandbox, tools are available as `external_(...)`. + +```typescript +// src/lib/tools/database-tools.ts +import { z } from 'zod' +import { toolDefinition } from '@tanstack/ai' + +export const queryTableTool = toolDefinition({ + name: 'queryTable', + description: 'Query a database table with filtering, column selection, ordering, and limits.', + inputSchema: z.object({ + table: z.enum(['customers', 'products', 'purchases']), + columns: z.array(z.string()).optional(), + where: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), + orderBy: z.string().optional(), + orderDirection: z.enum(['asc', 'desc']).optional(), + limit: z.number().optional(), + }), + outputSchema: z.object({ + rows: z.array(z.record(z.string(), z.any())), + totalMatchingRows: z.number(), + }), +}).server(async ({ table, columns, where, orderBy, orderDirection, limit }) => { + // Your database query logic here + // Inside the sandbox, the LLM calls: external_queryTable({ table: 'customers', ... }) +}) + +export const databaseTools = [queryTableTool] +``` + +## Step 3: Create the Code Mode Tool + +Call `createCodeMode()` with the driver, tools, and optional configuration. It returns a `tool` (the `execute_typescript` tool definition) and a `systemPrompt` that teaches the LLM how to use the sandbox. + +```typescript +import { createCodeMode } from '@tanstack/ai-code-mode' +import { createIsolateDriver } from '#/lib/create-isolate-driver' +import { databaseTools } from '#/lib/tools/database-tools' + +let codeModeCache: { + tool: ReturnType['tool'] + systemPrompt: string +} | null = null + +async function getCodeModeTools() { + if (!codeModeCache) { + const driver = await createIsolateDriver('node') + const { tool, systemPrompt } = createCodeMode({ + driver, + tools: databaseTools, + timeout: 60000, // max execution time in ms + memoryLimit: 128, // max memory in MB + }) + codeModeCache = { tool, systemPrompt } + } + return codeModeCache +} +``` + +Cache this too — `createCodeMode` only needs to run once. + +### Optional: Skill Bindings + +If you have additional functions you want available inside the sandbox (beyond your tools), pass them via `getSkillBindings`: + +```typescript +const { tool, systemPrompt } = createCodeMode({ + driver, + tools: databaseTools, + timeout: 60000, + memoryLimit: 128, + getSkillBindings: async () => myAdditionalBindings(), +}) +``` + +Skill bindings follow the `ToolBinding` interface from `@tanstack/ai-code-mode` and become available as `external_()` functions inside the sandbox alongside the tool-based ones. + +## Step 4: Wire Up the API Route + +Create a TanStack Start server route that accepts chat messages and streams responses via SSE: + +```typescript +// src/routes/_reporting/api.reports.ts +import { createFileRoute } from '@tanstack/react-router' +import { chat, maxIterations, toServerSentEventsStream } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' + +export const Route = createFileRoute('/_reporting/api/reports')({ + server: { + handlers: { + POST: async ({ request }) => { + const body = await request.json() + const { messages } = body + const abortController = new AbortController() + + const { tool, systemPrompt } = await getCodeModeTools() + + const stream = chat({ + adapter: anthropicText('claude-haiku-4-5'), + messages, + tools: [tool], // code mode tool + systemPrompts: [ + 'Your domain-specific system prompt here.', + systemPrompt, // code mode instructions + ], + agentLoopStrategy: maxIterations(20), // max tool-call loops + abortController, + maxTokens: 8192, + }) + + const sseStream = toServerSentEventsStream(stream, abortController) + + return new Response(sseStream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + }, + }, + }, +}) +``` + +Key points: +- Pass `tool` (from `createCodeMode`) in the `tools` array — this is the `execute_typescript` tool the LLM will call. +- Include `systemPrompt` (from `createCodeMode`) in `systemPrompts` — it tells the LLM how to write sandbox code. +- You can mix code mode tools with regular tools in the same `tools` array. +- Use `maxIterations()` as the `agentLoopStrategy` to cap tool-call rounds. + +## Step 5: Client-Side Chat + +Use `useChat` from `@tanstack/ai-react` with `fetchServerSentEvents`: + +```typescript +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' + +const { messages, sendMessage, isLoading, stop } = useChat({ + connection: fetchServerSentEvents('/api/reports'), + onCustomEvent: (eventType, data, context) => { + // Handle sandbox custom events (e.g., VM execution events) + }, +}) +``` + +Render tool calls for `execute_typescript` with the `CodeBlock`, `JavaScriptVM`, and `ExecutionResult` components from `src/components/db-demo/`. + +## How It Works at Runtime + +1. User sends a message via `useChat`. +2. Server calls `chat()` with the code mode tool. +3. LLM responds with an `execute_typescript` tool call containing TypeScript code. +4. The code runs in the sandboxed V8 isolate (or QuickJS fallback). +5. Inside the sandbox, the code calls `external_queryTable(...)` etc. — these bridge to your server-side tool handlers. +6. Results stream back to the client as SSE events. +7. The LLM sees the execution result and can respond with text or make further tool calls. + +## File Structure Reference + +``` +src/ +├── lib/ +│ ├── create-isolate-driver.ts # VM driver factory with caching +│ └── tools/ +│ └── database-tools.ts # Tool definitions (toolDefinition + .server()) +├── routes/ +│ └── _reporting/ +│ └── api.reports.ts # POST endpoint: chat() + code mode +└── components/ + └── db-demo/ + ├── CodeBlock.tsx # Renders execute_typescript code + ├── JavaScriptVM.tsx # Real-time VM event stream + ├── ExecutionResult.tsx # Execution outcome display + └── ChatInput.tsx # Chat input with auto-resize +``` + +## Netlify Deployment + +This project deploys on Netlify with: +- `netlify.toml` — build command, publish dir, Node version +- `@netlify/vite-plugin-tanstack-start` — SSR adapter for Netlify +- `@netlify/database` — managed Postgres accessed via `getDatabase()` +- Drizzle ORM with `drizzle-orm/netlify-db` adapter for typed schema access +- Migrations in `netlify/database/migrations/` diff --git a/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md b/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md new file mode 100644 index 0000000..2f587c9 --- /dev/null +++ b/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md @@ -0,0 +1,213 @@ +--- +name: tanstack-rsc +description: Enable React Server Components support in a TanStack Start + Vite project. Use when adding RSC to a TanStack Start app, configuring @vitejs/plugin-rsc, or debugging "pnpapi" / "pg-native" errors in Vite's RSC/SSR module runner. +--- + +# TanStack Start RSC Setup + +Turning on React Server Components in a TanStack Start project is a three-line change: install one plugin, add it to `vite.config.ts`, and flip a flag on `tanstackStart()`. The one gotcha is that a few Node-only dependencies must be externalized for Vite's `ssr` and `rsc` environments. + +## Prerequisites + +- **Node.js 24** — the RSC toolchain requires it. Update `netlify.toml` (or your deploy platform's config) and use `nvm use 24` locally. +- Existing TanStack Start project using `@tanstack/react-start` and Vite. + +## Step 1: Install the Vite Plugin + +```bash +pnpm add -D @vitejs/plugin-rsc +``` + +No other packages are needed. RSC helpers (`renderServerComponent`, `createCompositeComponent`) are already exported from `@tanstack/react-start/rsc` once the flag is on. + +## Step 2: Update `vite.config.ts` + +Two edits: + +1. Import and add `rsc()` to the plugin list. +2. Pass `{ rsc: { enabled: true } }` to `tanstackStart()`. + +```typescript +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import rsc from '@vitejs/plugin-rsc' +// ...other plugins + +export default defineConfig({ + plugins: [ + tailwindcss(), + tanstackStart({ rsc: { enabled: true } }), + rsc(), + netlifyPlugin(), + viteReact(), + ], +}) +``` + +**Plugin order matters**: `tanstackStart()` comes before `rsc()`, and `viteReact()` stays last. + +## Step 3: Set the Node Version + +In `netlify.toml`: + +```toml +[build.environment] + NODE_VERSION = "24" +``` + +For other platforms, set whatever env/config they use. Locally, use `nvm use 24`. + +## Step 4: Externalize Node-only Packages + +Some packages must never go through Vite's bundling — either because their dev-mode module runner can't resolve them, or because they depend on their on-disk layout at runtime. Externalize them in the `ssr` and `rsc` environments **and** declare them as top-level `dependencies` in `package.json` so Netlify's function packager (nft) can trace and include them. + +The exact set of packages to externalize depends on what your project uses — the list below is just an example for a project using `@tanstack/ai-code-mode` + `@netlify/database`. Add/remove entries based on the Node-only packages your own dependencies pull in (see the Generalized Pattern in Troubleshooting). + +The baseline set for that example: + +```typescript +const serverExternal = [ + 'esbuild', + 'pg', + 'isolated-vm', + 'quickjs-emscripten', + 'quickjs-emscripten-core', + '@jitl/quickjs-wasmfile-release-asyncify', + '@jitl/quickjs-wasmfile-release-sync', + '@jitl/quickjs-wasmfile-debug-asyncify', + '@jitl/quickjs-wasmfile-debug-sync', +] + +export default defineConfig({ + environments: { + ssr: { resolve: { external: serverExternal } }, + rsc: { resolve: { external: serverExternal } }, + }, + plugins: [/* ... */], +}) +``` + +Note: Vite's `resolve.external` accepts `string[]` only — no regex. + +```jsonc +// package.json — add the top-level packages as direct deps so pnpm +// hoists them to where nft can trace them. Transitive @jitl/* variants +// and their .wasm files come along for free. +{ + "dependencies": { + "esbuild": "^0.25.12", // used by @tanstack/ai-code-mode + "pg": "^8.20.0", // used by @netlify/database + "isolated-vm": "^6.0.2", // used by @tanstack/ai-isolate-node + "quickjs-emscripten": "^0.31.0" // used by @tanstack/ai-isolate-quickjs + } +} +``` + +**Why each is required**: + +- `esbuild` — its `lib/main.js` locates the native binary via a disk-relative path. Bundling → `The esbuild JavaScript API cannot be bundled`. +- `pg` — optional peer `pg-native` and runtime-resolved modules break Vite's dev module runner and produce subtly broken prod bundles. +- `isolated-vm` — native C++ addon (`.node` file). Can't be bundled at all. +- `quickjs-emscripten` family — the emscripten JS does `new URL("emscripten-module.wasm", import.meta.url)` to find its sibling `.wasm`. Bundling detaches them → `ENOENT: emscripten-module.wasm` at runtime. +- **Direct dependency declaration** — pnpm's symlinked `node_modules` can confuse nft when it tries to trace transitive deps. Hoisting via direct deps is reliable. Without this you get `Cannot find package 'X' imported from /var/task/v1/functions/server.mjs` at function boot. + +### How to spot an "externalize this" error + +Not every build error needs externalization — most are plain bugs. Externalization is the fix when a **Node-only package** is being forced through Vite's bundler or dev module runner. The signals: + +- **Where the error happens**: dev server request (Vite's `ssr`/`rsc` module runner) or at Netlify function cold-start (`/var/task/v1/functions/server.mjs`) — not in the browser. Browser-only errors are never fixed by `serverExternal`. +- **What the message looks like** (any of these patterns): + - `Cannot find module 'X'` or `Cannot find package 'X'` where `X` is a Node built-in shim (`pnpapi`), an optional peer (`pg-native`), or a platform-specific addon. + - `Could not resolve "X" imported by "Y"` where `X` is declared as optional or loaded via `try { require('X') } catch {}`. + - `The X JavaScript API cannot be bundled` — the package detects it was bundled and refuses to run. + - `ENOENT: no such file or directory` for a `.wasm`, `.node`, or other sibling asset that should live next to its JS. + - `Module did not self-register` or `NODE_MODULE_VERSION … requires NODE_MODULE_VERSION …` — native addon ABI mismatch (also needs `pnpm rebuild`, but must stay externalized). +- **What the offending package looks like**: native `.node` addon, ships a `.wasm` sibling, uses `import.meta.url` / `__dirname` to locate on-disk assets, has optional peer deps, or is pure-Node infrastructure (database drivers, bundlers, sandboxes) with no reason to go through Vite. + +If the error fits the shape above, add the top-level package to both `serverExternal` **and** `dependencies` in `package.json`. See the Troubleshooting section for the specific errors encountered in this template and their fixes. + +## Verification + +After the changes, start the dev server and load a page: + +```bash +netlify dev # or: pnpm dev +curl -I http://localhost:8888/ +``` + +You want a `200`. The first request is slow because Vite re-optimizes deps for the new `ssr` and `rsc` environments. + +## Troubleshooting + +### `Cannot find module 'pnpapi' imported from '…/esbuild/lib/main.js'` + +`esbuild`'s `lib/main.js` has an unconditional `import 'pnpapi'` for Yarn PnP support. Node's native loader swallows the failure; Vite's module runner surfaces it as a 500 on every route. It shows up whenever something (e.g. `@tanstack/ai-code-mode`) pulls `esbuild` into a server environment. + +**Fix**: add `'esbuild'` to `environments.ssr.resolve.external` and `environments.rsc.resolve.external`. + +### `Could not resolve "pg-native" imported by "pg"` + +`pg` (node-postgres) declares `pg-native` as an optional peer. Vite's runner can't resolve it, even though `pg` handles its absence gracefully at runtime. + +**Fix**: add `'pg'` to the same `external` arrays. (Externalizing `pg` is correct regardless — it's a pure-Node package with no reason to go through Vite.) + +### `Cannot find package 'X' imported from /var/task/v1/functions/server.mjs` + +The package is externalized, but nft couldn't find it when packaging the Netlify function. This usually happens with pnpm projects where the package is only a transitive dep. + +**Fix**: add the package to `dependencies` in `package.json` (use the version already resolved in `pnpm-lock.yaml`). + +### `The esbuild JavaScript API cannot be bundled` + +Runtime error from esbuild when it was inlined into `server.mjs`. esbuild's JS API computes a disk-relative path to its native binary, which breaks if the JS is bundled elsewhere. + +**Fix**: ensure `esbuild` is in `serverExternal` and in `dependencies` — both are required. + +### `Error: ENOENT: no such file or directory, open '…/emscripten-module.wasm'` + +`@tanstack/ai-isolate-quickjs` → `quickjs-emscripten` → `@jitl/quickjs-wasmfile-*` ships an emscripten JS module that finds its sibling `.wasm` file via `new URL("emscripten-module.wasm", import.meta.url)`. If Vite bundles the JS into `dist/server/assets/emscripten-module-*.js`, the WASM lookup fails at runtime because the `.wasm` isn't copied along. + +**Fix**: externalize the full quickjs-emscripten chain and add `quickjs-emscripten` to `dependencies`. The 4 `@jitl/quickjs-wasmfile-*` variants must be listed individually because `resolve.external` takes strings only — no regex. + +### `Module did not self-register` or `NODE_MODULE_VERSION … requires NODE_MODULE_VERSION …` for `isolated-vm` + +`isolated-vm` is a native C++ addon built against a specific Node ABI. If you switch Node versions (or install it on a different major than you're running), you have to rebuild. + +**Fix**: + +```bash +nvm use 24 +pnpm rebuild isolated-vm +``` + +Always externalize `isolated-vm` (native `.node` files cannot be bundled regardless). If isolated-vm fails at runtime in production, the code-mode runtime falls back to QuickJS — so make sure the QuickJS chain is externalized too (see above). + +### Generalized Pattern + +Any Node-only dependency that either (a) imports optional native addons, (b) uses `try/catch` around dynamic imports, or (c) depends on its on-disk layout at runtime is a candidate for externalization + direct-dep declaration. Symptoms look like: + +- `Cannot find module 'X'` where `X` is a platform-specific addon. +- `Could not resolve "X" imported by "Y"` for an optional peer dep. +- `The X JavaScript API cannot be bundled` or similar "bundled in wrong place" errors. + +The fix is always the same shape — add the top-level package to `serverExternal` AND to `dependencies` in `package.json`. + +## What You Get + +With RSC enabled, routes and components can use the server component model: + +- `import { renderServerComponent, createCompositeComponent } from '@tanstack/react-start/rsc'` +- Server-only components render on the server and stream as RSC payloads. +- `createCompositeComponent` lets server components accept client-provided slots (`children`, render-prop functions) for interactive UIs. + +Rendering RSCs is out of scope for this skill; see the TanStack Start docs. + +## Minimal Diff Summary + +A clean RSC upgrade touches exactly these files: + +- `package.json` — add `@vitejs/plugin-rsc` to `devDependencies`, and add `esbuild` + `pg` + `isolated-vm` + `quickjs-emscripten` (or other Node-only troublemakers) to `dependencies`. +- `vite.config.ts` — import `rsc`, add it to `plugins`, enable it on `tanstackStart`, and externalize the Node-only packages in both `ssr` and `rsc` environments. +- `netlify.toml` (or equivalent) — `NODE_VERSION = "24"`. + +If the diff is bigger than that, something unrelated is creeping in. From 5f18c7de87b9aee31d5cd3fd354c1896cb2af0c3 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 22 Apr 2026 09:19:56 -0700 Subject: [PATCH 2/2] Gate skill triggers on explicit customer intent Require specific trigger keywords ("RSC"/"React Server Components" or "code mode") in the skill descriptions so agents only invoke these skills when the customer explicitly asks for them. Made-with: Cursor --- .../.claude/skills/tanstack-ai-code-mode-ui/SKILL.md | 2 +- .../.claude/skills/tanstack-ai-code-mode/SKILL.md | 2 +- .../tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md index b8a108e..b0bb9dd 100644 --- a/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md +++ b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: tanstack-ai-code-mode-ui -description: Architect a code mode setup where UI generation is driven by the LLM through injected UI bindings. Use when designing a system where the model builds interfaces (dashboards, forms, visualizations, canvases, etc.) by calling UI functions from inside the sandbox, rather than returning structured UI payloads as tool results. +description: Architect a code mode setup where UI generation is driven by the LLM through injected UI bindings. Use only when the customer specifically requests "code mode" in relation to how the AI operates AND wants the model to build interfaces (dashboards, forms, visualizations, canvases, etc.) by calling UI functions from inside the sandbox, rather than returning structured UI payloads as tool results. --- # Generating UI from Code Mode diff --git a/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md index 82496a6..237fd21 100644 --- a/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md +++ b/frameworks/tanstack-start-react/.claude/skills/tanstack-ai-code-mode/SKILL.md @@ -1,6 +1,6 @@ --- name: tanstack-ai-code-mode -description: Set up and use TanStack AI Code Mode with sandboxed TypeScript execution. Use when adding code mode to a TanStack Start project, configuring isolate drivers, defining server tools, or wiring up the code mode chat API endpoint. +description: Set up and use TanStack AI Code Mode with sandboxed TypeScript execution. Use only when the customer specifically requests "code mode" in relation to how the AI operates — for example, adding code mode to a TanStack Start project, configuring isolate drivers, defining server tools, or wiring up the code mode chat API endpoint. --- # TanStack AI Code Mode Setup diff --git a/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md b/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md index 2f587c9..1d41705 100644 --- a/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md +++ b/frameworks/tanstack-start-react/.claude/skills/tanstack-rsc/SKILL.md @@ -1,6 +1,6 @@ --- name: tanstack-rsc -description: Enable React Server Components support in a TanStack Start + Vite project. Use when adding RSC to a TanStack Start app, configuring @vitejs/plugin-rsc, or debugging "pnpapi" / "pg-native" errors in Vite's RSC/SSR module runner. +description: Enable React Server Components support in a TanStack Start + Vite project. Use only when the customer specifically requests "RSC" or "React Server Components" in relation to their TanStack Start app — for example, adding RSC, configuring @vitejs/plugin-rsc, or debugging "pnpapi" / "pg-native" errors in Vite's RSC/SSR module runner. --- # TanStack Start RSC Setup