From 47d6d897ed0e60e0eeb8e47f0ec872ef1c6440ff Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Tue, 8 Sep 2026 09:46:29 +1000
Subject: [PATCH 01/18] Reframe TanStack AI messaging around who owns what
Restructure the AI landing page to walk through the ownership split
before the technical details: what TanStack AI handles (agent loop,
providers, durability, interrupts, sandboxes, tools) and what you own
(server, persistence, UI, deploy), each with real code from the docs.
Replace the tagline, description, and feature highlights with the same
framing. Honor the claim boundaries from the homepage plan: no lock-in
language, no counts in copy, no decorative numbering, and a closing set
of starting-point doc links.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 738 ++++++++++++++++++++-------
src/libraries/ai.tsx | 36 +-
src/libraries/libraries.ts | 4 +-
3 files changed, 563 insertions(+), 215 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index ea08d0d5b..833871953 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -11,16 +11,19 @@ import {
CubeIcon,
DatabaseIcon,
HardDrivesIcon,
+ LayoutIcon,
MicrophoneIcon,
PlugIcon,
RadioIcon,
RobotIcon,
+ ScalesIcon,
TerminalIcon,
WaveformIcon,
type Icon,
} from '@phosphor-icons/react'
import { getLibrary } from '~/libraries'
+import { CodeBlock } from '~/components/markdown/CodeBlock'
import {
LandingSection,
LandingSectionIntro,
@@ -185,8 +188,8 @@ export default function AiLanding() {
return (
}
prompt={aiPrompt}
promptLabel="Copy AI prompt"
@@ -194,33 +197,68 @@ export default function AiLanding() {
}
- title="Own both sides of the AI interaction."
- body="One route on the server, one hook in the client, and the transport between them is yours. Nothing here is a wrapper around a service we run."
+ eyebrow="Who owns what"
+ icon={}
+ title="One rule decides every API."
+ body="If it is hard to get right and identical in every app, we own it. If it stops fitting the day your app is no longer a prototype, you own it and we hand you typed helpers. Nothing here is a wrapper around a service we run."
/>
-
+
+
+
+
+
+ }
+ title="One call, one Response, any framework."
+ body="chat() takes messages and returns a stream. Turn it into a Response and return it from whatever route you already have. Auth, rate limits, and the deploy target stay in your code, where you can see them."
+ />
+
+
+
+
+
+ }
+ title="Your database. Your schema. Two functions."
+ body="A framework that owns your tables is great until you need soft delete, archiving, or a column it never imagined. So the core never sees your schema. Load a thread, save a thread, and the transcript, run status, and pending approvals land wherever you point them."
+ />
+
+
+
+
+
+ }
+ title="A stream survives the reload. The log is yours."
+ body="Every chunk is written to a log before it is delivered. Drop the socket, refresh the page, open a second tab, and the client replays from its last offset instead of paying for the model again. Start in memory, move to a hosted log, or write five methods against the store you already run."
+ />
+
+
+
+
+
+
+
+ }
+ title="Typed parts, honest states, no components to fight."
+ body="A message is a list of parts, and every part carries its own lifecycle. Text streams, a tool call moves through input, approval, and result, and an error is a state rather than an exception you missed. Render them yourself or register one component per part type."
+ />
+
}
- title="Define your agent tools once, re-use them on the server and client."
- body={
- <>
- Our chat() function allows you to define custom
- functions the LLM provider can call (tools) and you define the
- input and output to these functions once and re-use them across
- server and client by providing the specific implementations. Our
- library automatically calls these tools, stops and asks for
- approvals if needed, updates the input to the tools if the user
- changes it after the approval is granted and handles all the
- back and forth between the LLM provider and your app under the
- hood. You only define the tool, we handle the rest.
- >
- }
+ title="Define a tool once. Run it on either side."
+ body="One schema gives you the input and output types on the server and the client. The loop calls the tool, pauses for approval when you ask it to, applies the user's edits, and feeds the result back to the model."
/>
@@ -230,21 +268,16 @@ export default function AiLanding() {
}
- title="Swap an LLM provider. Keep the typesafety."
+ title="Swap the model. Keep the types."
body={
<>
- OpenRouter, OpenAI, Anthropic, Gemini, Vertex, Bedrock, Mistral,
- Groq, Grok, Ollama, Cohere, Perplexity, BytePlus, ElevenLabs,
- fal.ai, Lovable, LLM Gateway, and Vercel AI Gateway ship as
- official adapters, and openaiCompatible covers any endpoint that
- speaks the same shape, including a model on your own hardware.{' '}
- Every model from every provider is typesafe.
- When you need to send custom configuration for a specific model,
- send images, files and audio, or native tools like web search,
- every model is type-constrained and if it does not accept those
- options natively you learn about it at compile-time.
+ Connect directly to OpenAI, Anthropic, Gemini, Bedrock, Ollama,
+ and the rest, or through the gateway you choose, and
+ openaiCompatible covers any endpoint with the same shape.{' '}
+ Each model's options, modalities, and native
+ tools are typed, so an unsupported option fails at compile time.
>
}
/>
@@ -256,8 +289,8 @@ export default function AiLanding() {
centered
eyebrow="Open protocol"
icon={}
- title="AG-UI compliant, in both directions."
- body="The client sends AG-UI requests and consumes AG-UI events, with no proprietary stream format and no translation layer in between. That is what makes the agent on the other end replaceable: point the same client at a Python, Go, or PHP AG-UI runtime and it keeps working. The transport is yours too, whether that is SSE, HTTP streams, XHR, RPC, a raw async iterable, or a fetcher you wrote. Nothing to sign up for, no key to hand over, no traffic through us."
+ title="AG-UI in both directions."
+ body="The client speaks AG-UI with no proprietary stream format in between, so the agent on the other end is replaceable: point it at a Python, Go, or PHP runtime and it keeps working. SSE, HTTP streams, XHR, RPC, or a fetcher you wrote. No TanStack service sits in the request path."
/>
@@ -265,10 +298,10 @@ export default function AiLanding() {
}
- title="Sandboxes, code mode, MCP, memory, compaction, skills and more."
- body="We offer more than just a simple chat interface. We allow you to build any AI feature you might need, from automated AI workflows in CI, to web apps consuming LLM providers, chatbots and more."
+ title="Sandboxes, Code Mode, MCP, memory, compaction."
+ body="Each one is a separate package with the same shape as the core. Reach for it when the task needs it, and leave it out of the bundle when it does not."
/>
@@ -279,8 +312,8 @@ export default function AiLanding() {
}
- title="Need to generate images, video, audio and more? We have you covered."
- body="We equally care about every generation, not just text. We offer you a whole suite of utilities to generate images, video, speech, transcription, music and realtime voice with full observability and cost tracking."
+ title="Images, video, speech, and realtime voice."
+ body="The same adapters and the same persistence cover every modality, with progress updates and cost tracking built in."
/>
@@ -291,183 +324,469 @@ export default function AiLanding() {
}
- title="Full observability of every action with our devtools"
- body="Our devtools show you every detail about every part of your system, whether you are generating images, video or using chat you can see every action that happened on both the server and the client and easily debug what is going on on both sides."
+ title="See every action on both sides."
+ body="Every tool call, interrupt, memory recall, and finish reason, on the server and in the client, in one timeline."
/>
+
+
+ }
+ title="Pick the page that matches your next hour."
+ body="Each one is a short guide with copyable code, not a tour."
+ />
+
+
)
}
-function CodeLine({
- children,
- indent = 0,
+const codeWindowClass =
+ 'm-0 min-w-0 rounded-none border-0 [&>div:first-child]:rounded-none [&_pre]:max-h-[26rem] [&_pre]:overflow-auto [&_pre]:rounded-none [&_pre]:text-[11px] [&_pre]:leading-5 sm:[&_pre]:text-xs'
+
+function CodeTabs({
+ label,
+ samples,
}: {
- children?: React.ReactNode
- indent?: number
+ label: string
+ samples: Array<{ code: string; file: string; name: string }>
}) {
- return
{children || ' '}
-}
-
-function Kw({ children }: { children: React.ReactNode }) {
- return {children}
-}
+ const [activeIndex, setActiveIndex] = React.useState(0)
+ const sample = samples[activeIndex] ?? samples[0]
-// ponytail: the code surface is always dark, so these use fixed token colors.
-// --landing-accent-bright resolves to a dark terracotta in light mode and is
-// unreadable here.
-function Fn({ children }: { children: React.ReactNode }) {
- return {children}
+ return (
+
+
+ {samples.map((item, index) => (
+
+ ))}
+
+
+ {sample.code}
+
+
+ )
}
-function Str({ children }: { children: React.ReactNode }) {
- return {children}
+const ownership = {
+ handled: [
+ {
+ label: 'Agent loop',
+ body: 'Tool calls, stop conditions, and every model round-trip. Easy to start, wrong in a hundred small ways.',
+ },
+ {
+ label: 'Providers',
+ body: 'Every major provider behind one call, each model typed down to its options and modalities.',
+ },
+ {
+ label: 'Durability',
+ body: 'A dropped socket, a reload, or a restart replays from a log. The model is never re-run.',
+ },
+ {
+ label: 'Interrupts',
+ body: 'A run pauses for a human, then resumes at the exact step with their edits applied.',
+ },
+ {
+ label: 'Sandboxes',
+ body: 'Coding agents and Code Mode run in an isolate or a container. Their activity is ordinary events.',
+ },
+ {
+ label: 'Tools',
+ body: 'One schema, typed on both ends, executed on the server or the client.',
+ },
+ ],
+ owned: [
+ {
+ label: 'Server',
+ body: 'Any route, any runtime. Your auth check sits next to the call, not behind a config flag.',
+ },
+ {
+ label: 'Persistence',
+ body: 'Your database and your schema. Two store functions are the whole contract.',
+ },
+ {
+ label: 'UI',
+ body: 'Typed messages, parts, and states. You render them.',
+ },
+ {
+ label: 'Deploy',
+ body: 'Your requests, credentials, and data never pass through TanStack.',
+ },
+ ],
}
-function Cmt({ children }: { children: React.ReactNode }) {
- return {children}
+function OwnershipMap() {
+ return (
+
- Swap React for any other framework and keep your server-side code
- identical.
+
+ Add a runs store to rejoin a run after a reload and an interrupts store
+ to hold an approval for days. Start with memoryPersistence() on the
+ server or localStoragePersistence() in the browser, and swap it out
+ without touching the route.
)
}
+
+const startingPoints = [
+ { label: 'Build streaming chat', to: 'getting-started/quick-start' },
+ {
+ label: 'Start from a server route',
+ to: 'getting-started/quick-start-server',
+ },
+ { label: 'Add persistence', to: 'persistence/overview' },
+ { label: 'Compare with Vercel AI SDK', to: 'comparison/vercel-ai-sdk' },
+]
+
+function StartingPoints() {
+ const { version } = useParams({ strict: false })
+ const library = getLibrary('ai')
+
+ return (
+
+ {startingPoints.map((point) => (
+
+
+ {point.label}
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/libraries/ai.tsx b/src/libraries/ai.tsx
index f6da8795c..f14a3ccbb 100644
--- a/src/libraries/ai.tsx
+++ b/src/libraries/ai.tsx
@@ -7,47 +7,43 @@ const textStyles = `text-category-data`
export const aiProject = {
...ai,
- description: `TanStack AI is a pluggable AI ecosystem that makes it easy for you to build AI features into your apps. Provide tools to LLMs, interrupt chat for user approval, run agents in sandboxes, build headless chat UI, stream from your server to your client, and connect to any AG-UI compatible server or client. Bring your own infrastructure. We offer the pluggable APIs to build on top of.`,
+ description: `TanStack AI gives you composable building blocks for everything you should not write yourself: the agent loop, provider adapters, durability, interrupts, sandboxes, and tools. It leaves you everything a one-size-fits-all framework gets wrong past the prototype: your server, your database, your UI. Typed end to end, AG-UI native, and no TanStack service in the request path.`,
latestBranch: 'main',
defaultDocs: 'getting-started/overview',
featureHighlights: [
{
- title: 'A Real Agent Loop',
+ title: "We Build What You Shouldn't",
icon: ,
description: (
- chat() drives the loop and you control every part of it:
- isomorphic tools you place on the client or the server, composable{' '}
- {`(state) => boolean`} stop strategies, and interrupts
- that pause a run for human approval and resume exactly where it
- stopped, with no database required.
+ chat() drives the agent loop: typed tools that run on the
+ server or the client, stop strategies, interrupts that pause a run for
+ a human and resume at the exact step, and a durability log that
+ survives a dropped socket or a reload without re-running the model.
),
},
{
- title: 'Bring Your Own Everything',
+ title: 'You Own What Outgrows a Framework',
icon: ,
description: (
- Your provider, server, transport, auth, and deploy target. Official
- adapters for OpenRouter, OpenAI, Anthropic, Gemini, Vertex, Bedrock,
- Mistral, Groq, Grok, Ollama, Cohere, Perplexity, BytePlus, ElevenLabs,
- fal.ai, Lovable, LLM Gateway, and Vercel AI Gateway, plus{' '}
- openaiCompatible for anything else. Import only what you
- use: every activity is a separate, tree-shakeable module.
+ Your route, your database, your UI. Persistence is two store functions
+ against your own schema. The server is one call and a Response in any
+ framework. Your requests, credentials, and data never pass through
+ TanStack.
),
},
{
- title: 'Headless, Not Opinionated',
+ title: 'One Mental Model, Typed End to End',
icon: ,
description: (
- A framework-free core with React, Vue, Solid, Svelte, Preact, Angular,
- and React Native bindings on top, plus official Octane bindings from
- the Octane team. All of them speak native AG-UI over SSE, HTTP
- streams, XHR, RPC, or your own transport. No components to fight, no
- styles to override.
+ Every major provider behind one call, each model typed down to its
+ options and modalities. A framework-free core with React, Vue, Solid,
+ Svelte, Preact, Angular, React Native, and Octane bindings, all
+ speaking native AG-UI over the transport you choose.
),
},
diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts
index 3b0f50475..3510d3430 100644
--- a/src/libraries/libraries.ts
+++ b/src/libraries/libraries.ts
@@ -691,9 +691,9 @@ export const ai: LibrarySlim = {
...categoryStyles.data,
name: 'TanStack AI',
to: '/ai/latest',
- tagline: 'The headless agent framework for TypeScript. Bring your own stack',
+ tagline: 'Composable AI building blocks. Your server, database, and UI',
description:
- 'TanStack AI is a pluggable AI ecosystem that makes it easy for you to build AI features into your apps. Provide tools to LLMs, interrupt chat for user approval, run agents in sandboxes, build headless chat UI, stream from your server to your client, and connect to any AG-UI compatible server or client. Bring your own infrastructure. We offer the pluggable APIs to build on top of.',
+ 'TanStack AI gives you composable building blocks for everything you should not write yourself: the agent loop, provider adapters, durability, interrupts, sandboxes, and tools. It leaves you everything a one-size-fits-all framework gets wrong past the prototype: your server, your database, your UI. Typed end to end, AG-UI native, and no TanStack service in the request path.',
badge: 'RC',
repo: 'tanstack/ai',
frameworks: [
From 79ae06fd114c172955b1790950eb912fcc8f604b Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Tue, 8 Sep 2026 10:10:59 +1000
Subject: [PATCH 02/18] Replace AI hero graphic with a wireframe placeholder
Drop the client graph and chat animation. The headline now answers
what it is and why in one line.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 603 ++-------------------------
1 file changed, 30 insertions(+), 573 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index 833871953..9f1b818e9 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -66,93 +66,6 @@ const providers = [
},
]
-type AiHeroServer = {
- detail?: string
- dotted?: boolean
- kind?: 'tanstack'
- label: string
-}
-
-type GraphNodePosition = {
- height: number
- label: string
- width: number
- x: number
- y: number
-}
-
-type GraphPoint = {
- x: number
- y: number
-}
-
-const aiHeroClients = [
- 'Vanilla',
- 'React',
- 'Vue',
- 'Solid',
- 'Svelte',
- 'Preact',
- 'Angular',
- 'Octane',
-]
-const aiHeroServers: Array = [
- { label: 'TanStack AI', detail: 'Server', kind: 'tanstack' },
- { label: 'Python', dotted: true },
- { label: 'Go', dotted: true },
- { label: 'PHP', dotted: true },
-]
-const aiHeroProviders = ['OpenRouter', 'OpenAI', 'Anthropic', 'Gemini']
-// ponytail: 8 clients on a fixed 4x2 grid; recompute the columns if the list changes length
-const graphClientNodes = aiHeroClients.map((label, index) => ({
- label,
- x: [10, 112, 214, 316][index % 4] ?? 112,
- y: index < 4 ? 36 : 84,
- width: 94,
- height: 36,
-}))
-const graphAgUiNode: GraphNodePosition & {
- kind: 'tanstack'
-} = {
- label: 'TanStack AI Client',
- kind: 'tanstack',
- x: 142,
- y: 138,
- width: 136,
- height: 58,
-}
-const graphServerNodes = aiHeroServers.map((server, index) => ({
- ...server,
- x: [38, 178, 254, 326][index] ?? 178,
- y: index === 0 ? 254 : 260,
- width: index === 0 ? 124 : 56,
- height: index === 0 ? 54 : 42,
-}))
-const graphProviderNodes = aiHeroProviders.map((label, index) => ({
- label,
- x: 18 + index * 98,
- y: 352,
- width: 78,
- height: 34,
-}))
-const aiHeroMessages = [
- {
- user: 'Build the invoice agent on our stack, not yours.',
- assistant:
- 'Done. Headless client in your app, the agent loop on your server, AG-UI between them. No gateway, no hosted state.',
- },
- {
- user: 'It should ask before it charges a card.',
- assistant:
- 'chargeCard is marked needsApproval, so the run ends as an interrupt. Resolve it and the loop continues from that exact step.',
- },
- {
- user: 'And if we move off this provider?',
- assistant:
- 'Swap the adapter. Your tools, events, and UI never learn the difference.',
- },
-]
-
// ponytail: the shared --landing-accent-ink is pure black, which reads badly on the
// orange accent fill. Darken the fill instead and use white text on it.
const accentFillClass =
@@ -177,20 +90,13 @@ function AdapterDocsLink() {
)
}
-type AiHeroChatMessage = {
- assistant: string
- id: string
- isStreaming: boolean
- user: string
-}
-
export default function AiLanding() {
return (
}
+ hero={}
prompt={aiPrompt}
promptLabel="Copy AI prompt"
>
@@ -787,483 +693,6 @@ function MessageParts() {
)
}
-function AiGraphChatHero() {
- const [activeClient, setActiveClient] = React.useState(0)
- const [activeServer, setActiveServer] = React.useState(0)
- const [activeProvider, setActiveProvider] = React.useState(0)
- const [chatMessages, setChatMessages] = React.useState<
- Array
- >([])
- const [typingUserMessage, setTypingUserMessage] = React.useState('')
- const activeServerNode = graphServerNodes[activeServer] ?? graphServerNodes[0]
- const chatScrollRef = React.useRef(null)
- const chatLockedToBottomRef = React.useRef(true)
-
- React.useEffect(() => {
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
- return
- }
-
- const clientIntervalId = window.setInterval(() => {
- setActiveClient((current) => (current + 1) % aiHeroClients.length)
- }, 2300)
- const serverIntervalId = window.setInterval(() => {
- setActiveServer((current) => (current + 1) % aiHeroServers.length)
- }, 3300)
- const providerIntervalId = window.setInterval(() => {
- setActiveProvider((current) => (current + 1) % aiHeroProviders.length)
- }, 4100)
-
- return () => {
- window.clearInterval(clientIntervalId)
- window.clearInterval(serverIntervalId)
- window.clearInterval(providerIntervalId)
- }
- }, [])
-
- React.useEffect(() => {
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
- const message = aiHeroMessages[0]
-
- setChatMessages([
- {
- ...message,
- id: 'reduced-motion-example',
- isStreaming: false,
- },
- ])
- return
- }
-
- let cancelled = false
- const timeouts: Array = []
-
- const addTimeout = (callback: () => void, delay: number) => {
- const timeoutId = window.setTimeout(callback, delay)
- timeouts.push(timeoutId)
- }
-
- const streamAssistantResponse = (
- id: string,
- response: string,
- onComplete: () => void,
- ) => {
- let currentIndex = 0
-
- const streamChunk = () => {
- if (cancelled) {
- return
- }
-
- if (currentIndex < response.length) {
- const chunkSize = 2 + Math.floor(Math.random() * 7)
- const nextIndex = Math.min(currentIndex + chunkSize, response.length)
- const nextText = response.slice(0, nextIndex)
-
- setChatMessages((currentMessages) =>
- currentMessages.map((message) =>
- message.id === id
- ? { ...message, assistant: nextText, isStreaming: true }
- : message,
- ),
- )
-
- currentIndex = nextIndex
- addTimeout(streamChunk, 22 + Math.floor(Math.random() * 58))
- return
- }
-
- setChatMessages((currentMessages) =>
- currentMessages.map((message) =>
- message.id === id ? { ...message, isStreaming: false } : message,
- ),
- )
- addTimeout(onComplete, 1600)
- }
-
- addTimeout(streamChunk, 450)
- }
-
- const typeUserMessage = (
- messageIndex: number,
- onComplete: (id: string) => void,
- ) => {
- const message = aiHeroMessages[messageIndex]
- let currentIndex = 0
-
- setTypingUserMessage('')
-
- const typeChar = () => {
- if (cancelled) {
- return
- }
-
- if (currentIndex < message.user.length) {
- currentIndex += 1
- setTypingUserMessage(message.user.slice(0, currentIndex))
- addTimeout(typeChar, 30 + Math.floor(Math.random() * 40))
- return
- }
-
- addTimeout(() => {
- const id = `${messageIndex}-${Date.now()}`
-
- setTypingUserMessage('')
- setChatMessages((currentMessages) => [
- ...currentMessages.slice(-1),
- {
- assistant: '',
- id,
- isStreaming: true,
- user: message.user,
- },
- ])
- onComplete(id)
- }, 320)
- }
-
- typeChar()
- }
-
- const playMessage = (messageIndex: number) => {
- if (cancelled) {
- return
- }
-
- const nextMessageIndex = messageIndex % aiHeroMessages.length
- const message = aiHeroMessages[nextMessageIndex]
-
- typeUserMessage(nextMessageIndex, (id) => {
- streamAssistantResponse(id, message.assistant, () => {
- playMessage(nextMessageIndex + 1)
- })
- })
- }
-
- addTimeout(() => playMessage(0), 700)
-
- return () => {
- cancelled = true
- timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId))
- }
- }, [])
-
- React.useEffect(() => {
- const element = chatScrollRef.current
- if (!element) {
- return
- }
-
- const handleScroll = () => {
- const distanceFromBottom =
- element.scrollHeight - element.scrollTop - element.clientHeight
-
- chatLockedToBottomRef.current = distanceFromBottom < 72
- }
-
- element.addEventListener('scroll', handleScroll, { passive: true })
- return () => element.removeEventListener('scroll', handleScroll)
- }, [])
-
- React.useEffect(() => {
- const frameId = window.requestAnimationFrame(() => {
- const element = chatScrollRef.current
-
- if (element && chatLockedToBottomRef.current) {
- element.scrollTop = element.scrollHeight
- }
- })
-
- return () => window.cancelAnimationFrame(frameId)
- }, [chatMessages])
-
- return (
-
-
- A client graph shows eight UI adapters converging on the TanStack AI
- Client over AG-UI, then reaching an agent runtime in TypeScript, Python,
- Go, or PHP, and interchangeable model providers.
-
-
-
-
+ )
+}
From c46ffa627fc6ebb93680df3014eb23556d1cc320 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Tue, 8 Sep 2026 12:59:44 +1000
Subject: [PATCH 03/18] Add write-once hero, compiler visual, and request path
to AI landing
Hero: a fixed tools.ts window beside a runs-anywhere window that
cycles server framework, provider, and UI framework one at a time.
Providers: a model picker that squiggles the image part and prints the
TypeScript error when the selected model is text-only.
Server: the three-node request path with no TanStack hop.
Restore the original AG-UI section copy.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 696 +++++++++++++++++++++------
1 file changed, 561 insertions(+), 135 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index 9f1b818e9..1995d1b5e 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -24,6 +24,7 @@ import {
import { getLibrary } from '~/libraries'
import { CodeBlock } from '~/components/markdown/CodeBlock'
+import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion'
import {
LandingSection,
LandingSectionIntro,
@@ -38,65 +39,18 @@ const aiPrompt = [
'Never introduce a hosted gateway, a prescribed UI kit, or a provider-specific wire format. Keep provider capabilities honest: model options, tool support, and modality-specific results stay typed at the adapter boundary, and media or realtime primitives appear only where the selected model supports them.',
].join(' ')
-const providers = [
- {
- name: 'OpenRouter',
- model: 'any of 300+ models',
- capabilities: ['text', 'reasoning', 'tools', 'image'],
- },
- {
- name: 'OpenAI',
- model: 'gpt-5',
- capabilities: ['text', 'reasoning', 'tools', 'image'],
- },
- {
- name: 'Anthropic',
- model: 'claude-sonnet-4',
- capabilities: ['text', 'reasoning', 'tools'],
- },
- {
- name: 'Gemini',
- model: 'gemini-2.5-pro',
- capabilities: ['text', 'reasoning', 'tools', 'media'],
- },
- {
- name: 'Ollama',
- model: 'local model',
- capabilities: ['text', 'tools'],
- },
-]
-
// ponytail: the shared --landing-accent-ink is pure black, which reads badly on the
// orange accent fill. Darken the fill instead and use white text on it.
const accentFillClass =
'bg-[linear-gradient(135deg,color-mix(in_srgb,var(--landing-accent)_84%,black),color-mix(in_srgb,var(--landing-accent)_52%,black))] text-white'
-function AdapterDocsLink() {
- const { version } = useParams({ strict: false })
- const library = getLibrary('ai')
-
- return (
-
- See the adapter docs for more.
-
- )
-}
-
export default function AiLanding() {
return (
}
+ hero={}
prompt={aiPrompt}
promptLabel="Copy AI prompt"
>
@@ -121,6 +75,7 @@ export default function AiLanding() {
/>
+
@@ -176,16 +131,8 @@ export default function AiLanding() {
}
- title="Swap the model. Keep the types."
- body={
- <>
- Connect directly to OpenAI, Anthropic, Gemini, Bedrock, Ollama,
- and the rest, or through the gateway you choose, and
- openaiCompatible covers any endpoint with the same shape.{' '}
- Each model's options, modalities, and native
- tools are typed, so an unsupported option fails at compile time.
- >
- }
+ title="The types know which model you picked."
+ body="Select a model and TypeScript narrows its options, capabilities, and input modalities. Pass an image to a text-only model and it fails in the editor, not in production. Connect directly to the provider or through the gateway you choose."
/>
@@ -195,8 +142,8 @@ export default function AiLanding() {
centered
eyebrow="Open protocol"
icon={}
- title="AG-UI in both directions."
- body="The client speaks AG-UI with no proprietary stream format in between, so the agent on the other end is replaceable: point it at a Python, Go, or PHP runtime and it keeps working. SSE, HTTP streams, XHR, RPC, or a fetcher you wrote. No TanStack service sits in the request path."
+ title="AG-UI compliant, in both directions."
+ body="The client sends AG-UI requests and consumes AG-UI events, with no proprietary stream format and no translation layer in between. That is what makes the agent on the other end replaceable: point the same client at a Python, Go, or PHP AG-UI runtime and it keeps working. The transport is yours too, whether that is SSE, HTTP streams, XHR, RPC, a raw async iterable, or a fetcher you wrote. Nothing to sign up for, no key to hand over, no traffic through us."
/>
@@ -781,63 +728,6 @@ function ToolBoundary() {
)
}
-function ProviderWorkbench() {
- const [activeIndex, setActiveIndex] = React.useState(0)
- const provider = providers[activeIndex] ?? providers[0]
-
- return (
-
-
- Types narrow to this exact model: its options, its capabilities, its
- input modalities. Pass an image to a text-only model and it fails at
- compile time, not in production.
-
+ Every adapter package and what it can do, generated from each package's
+ model metadata on {generated}. A number is how many models the adapter
+ types for that activity. A check means the catalog is open-ended, so the
+ adapter accepts any model the provider serves. Click a row for the model
+ list.
+
+ Every adapter package and what it can do, generated from the
+ packages themselves. A count is how many model ids the adapter types
+ for that activity. A check means the catalog is open-ended, so the
+ adapter accepts whatever the provider serves.
+
- Every adapter package and what it can do, generated from each package's
- model metadata on {generated}. A number is how many models the adapter
- types for that activity. A check means the catalog is open-ended, so the
- adapter accepts any model the provider serves. Click a row for the model
- list.
-
- )
-}
diff --git a/src/routes/_library/ai.coverage.tsx b/src/routes/_library/ai.coverage.tsx
new file mode 100644
index 000000000..711121121
--- /dev/null
+++ b/src/routes/_library/ai.coverage.tsx
@@ -0,0 +1,30 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { AiCoverage } from '~/components/ai-coverage/AiCoverage'
+import { docsConfigQueryOptions } from '~/queries/docsConfig'
+import { aiCoverageQueryOptions } from '~/utils/ai-coverage'
+import { seo } from '~/utils/seo'
+
+export const Route = createFileRoute('/_library/ai/coverage')({
+ staleTime: 1000 * 60 * 5,
+ loader: async ({ context: { queryClient } }) => {
+ const [config] = await Promise.all([
+ queryClient.ensureQueryData(docsConfigQueryOptions('ai', 'latest')),
+ queryClient.ensureQueryData(aiCoverageQueryOptions('latest')),
+ ])
+ return { config, version: 'latest' }
+ },
+ head: () => ({
+ meta: seo({
+ title: 'TanStack AI Coverage',
+ description:
+ 'Every TanStack AI adapter and the activities, models, and modalities it supports, generated from the packages.',
+ }),
+ }),
+ component: AiCoverageRoute,
+})
+
+function AiCoverageRoute() {
+ const { data: coverage } = useSuspenseQuery(aiCoverageQueryOptions('latest'))
+ return
+}
From b48a8ec57282c0acd403eba11e8ce2a0c263c249 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Tue, 8 Sep 2026 20:30:02 +1000
Subject: [PATCH 08/18] Add an Adapters tab to the AI docs nav with a runtime
adapter list
New adapters nav tab that lands on /ai/adapters, a plain list of every
adapter package and what it supports, fetched from the ai repo at
request time. The Adapters and Community Adapters doc sections move
under the tab. Replaces the coverage page.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/LibraryLayout.tsx | 39 +-
src/components/ai-adapters/AiAdapters.tsx | 91 +++++
src/components/ai-coverage/AiCoverage.tsx | 367 ------------------
src/routeTree.gen.ts | 34 +-
.../{ai.coverage.tsx => ai.adapters.tsx} | 14 +-
src/utils/docsNavTabs.ts | 11 +
6 files changed, 148 insertions(+), 408 deletions(-)
create mode 100644 src/components/ai-adapters/AiAdapters.tsx
delete mode 100644 src/components/ai-coverage/AiCoverage.tsx
rename src/routes/_library/{ai.coverage.tsx => ai.adapters.tsx} (65%)
diff --git a/src/components/LibraryLayout.tsx b/src/components/LibraryLayout.tsx
index 9e42d06d4..a67d43583 100644
--- a/src/components/LibraryLayout.tsx
+++ b/src/components/LibraryLayout.tsx
@@ -11,7 +11,11 @@ import { useMediaQuery } from '~/utils/useMediaQuery'
import { useClickOutside } from '~/hooks/useClickOutside'
import { last } from '~/utils/utils'
import type { ConfigSchema, MenuItem } from '~/utils/config'
-import { getActiveDocsNavTabId, getTabbedMenuConfig } from '~/utils/docsNavTabs'
+import {
+ getActiveDocsNavTabId,
+ getTabbedMenuConfig,
+ type DocsNavTabId,
+} from '~/utils/docsNavTabs'
import { getLibrary, type Framework, type LibraryId } from '~/libraries'
import { categoryOf, categoryTextColor } from '~/libraries/categories'
import { frameworkOptions } from '~/libraries/frameworks'
@@ -660,6 +664,13 @@ function DocNavigationCard({
)
}
+const libraryTabLandings: Partial<
+ Record>>
+> = {
+ ai: { adapters: '/ai/adapters' },
+ charts: { examples: '/charts/catalog' },
+}
+
const useMenuConfig = ({
config,
repo,
@@ -688,7 +699,7 @@ const useMenuConfig = ({
]
const aiMenuItems: MenuItem['children'] = [
- { label: 'Coverage', to: '/ai/coverage', tab: 'home' },
+ { label: 'All adapters', to: '/ai/adapters', tab: 'adapters' },
]
const localMenu: MenuItem = {
@@ -870,21 +881,15 @@ export function LibraryLayout({
const tabbedMenuConfig = React.useMemo(() => {
const tabs = getTabbedMenuConfig(menuConfig)
-
- return libraryId === 'charts'
- ? tabs.map((tab) =>
- tab.id === 'examples'
- ? {
- ...tab,
- firstItem: {
- label: 'Examples',
- to: '/charts/catalog',
- tab: 'examples',
- },
- }
- : tab,
- )
- : tabs
+ // Tabs whose landing page is a library page rather than a doc.
+ const landing = libraryTabLandings[libraryId] ?? {}
+
+ return tabs.map((tab) => {
+ const to = landing[tab.id]
+ return to
+ ? { ...tab, firstItem: { label: tab.label, to, tab: tab.id } }
+ : tab
+ })
}, [libraryId, menuConfig])
const activeTabId = React.useMemo(() => {
diff --git a/src/components/ai-adapters/AiAdapters.tsx b/src/components/ai-adapters/AiAdapters.tsx
new file mode 100644
index 000000000..09ed8dffa
--- /dev/null
+++ b/src/components/ai-adapters/AiAdapters.tsx
@@ -0,0 +1,91 @@
+import { Link } from '@tanstack/react-router'
+import type { AiCoverage } from '~/utils/ai-coverage'
+
+const activityLabels: Record = {
+ chat: 'Chat',
+ image: 'Image',
+ video: 'Video',
+ speech: 'Speech',
+ transcription: 'Transcription',
+ audio: 'Audio',
+ realtime: 'Realtime',
+ embedding: 'Embedding',
+ rerank: 'Rerank',
+ search: 'Search',
+ harness: 'Coding agent',
+}
+
+export function AiAdapters({ coverage }: { coverage: AiCoverage | null }) {
+ if (!coverage) {
+ return (
+
+ The adapter list is not available on this branch yet.
+
+ )
+ }
+
+ return (
+
+
Adapters
+
+ One package per provider. A number is how many model ids the package
+ types for that activity. "Any" means the package accepts whatever the
+ provider serves.
+
- Every adapter package and what it can do, generated from the
- packages themselves. A count is how many model ids the adapter types
- for that activity. A check means the catalog is open-ended, so the
- adapter accepts whatever the provider serves.
-
- The adapter list is not available on this branch yet.
-
- )
- }
-
- return (
-
-
Adapters
-
- One package per provider. A number is how many model ids the package
- types for that activity. "Any" means the package accepts whatever the
- provider serves.
-
- )
-}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index b4d1be732..08bfed59b 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -165,7 +165,6 @@ import { Route as AdminShowcasesIdRouteImport } from './routes/admin/showcases_.
import { Route as AdminRolesRoleIdRouteImport } from './routes/admin/roles.$roleId'
import { Route as AdminFeedbackIdRouteImport } from './routes/admin/feedback_.$id'
import { Route as LibraryChartsCatalogRouteImport } from './routes/_library/charts.catalog'
-import { Route as LibraryAiAdaptersRouteImport } from './routes/_library/ai.adapters'
import { Route as LibraryLibraryIdVersionRouteImport } from './routes/_library/$libraryId/$version'
import { Route as IntentRegistryPackageNameIndexRouteImport } from './routes/intent/registry/$packageName.index'
import { Route as LibraryWorkflowVersionIndexRouteImport } from './routes/_library/workflow.$version.index'
@@ -1019,11 +1018,6 @@ const LibraryChartsCatalogRoute = LibraryChartsCatalogRouteImport.update({
path: '/charts/catalog',
getParentRoute: () => LibraryRoute,
} as any)
-const LibraryAiAdaptersRoute = LibraryAiAdaptersRouteImport.update({
- id: '/ai/adapters',
- path: '/ai/adapters',
- getParentRoute: () => LibraryRoute,
-} as any)
const LibraryLibraryIdVersionRoute = LibraryLibraryIdVersionRouteImport.update({
id: '/$version',
path: '/$version',
@@ -1453,7 +1447,6 @@ export interface FileRoutesByFullPath {
'/showcase/': typeof ShowcaseIndexRoute
'/stats/': typeof StatsIndexRoute
'/$libraryId/$version': typeof LibraryLibraryIdVersionRouteWithChildren
- '/ai/adapters': typeof LibraryAiAdaptersRoute
'/charts/catalog': typeof LibraryChartsCatalogRouteWithChildren
'/admin/feedback/$id': typeof AdminFeedbackIdRoute
'/admin/roles/$roleId': typeof AdminRolesRoleIdRoute
@@ -1656,7 +1649,6 @@ export interface FileRoutesByTo {
'/shop': typeof ShopIndexRoute
'/showcase': typeof ShowcaseIndexRoute
'/stats': typeof StatsIndexRoute
- '/ai/adapters': typeof LibraryAiAdaptersRoute
'/admin/feedback/$id': typeof AdminFeedbackIdRoute
'/admin/roles/$roleId': typeof AdminRolesRoleIdRoute
'/admin/showcases/$id': typeof AdminShowcasesIdRoute
@@ -1867,7 +1859,6 @@ export interface FileRoutesById {
'/showcase/': typeof ShowcaseIndexRoute
'/stats/': typeof StatsIndexRoute
'/_library/$libraryId/$version': typeof LibraryLibraryIdVersionRouteWithChildren
- '/_library/ai/adapters': typeof LibraryAiAdaptersRoute
'/_library/charts/catalog': typeof LibraryChartsCatalogRouteWithChildren
'/admin/feedback_/$id': typeof AdminFeedbackIdRoute
'/admin/roles/$roleId': typeof AdminRolesRoleIdRoute
@@ -2081,7 +2072,6 @@ export interface FileRouteTypes {
| '/showcase/'
| '/stats/'
| '/$libraryId/$version'
- | '/ai/adapters'
| '/charts/catalog'
| '/admin/feedback/$id'
| '/admin/roles/$roleId'
@@ -2284,7 +2274,6 @@ export interface FileRouteTypes {
| '/shop'
| '/showcase'
| '/stats'
- | '/ai/adapters'
| '/admin/feedback/$id'
| '/admin/roles/$roleId'
| '/admin/showcases/$id'
@@ -2494,7 +2483,6 @@ export interface FileRouteTypes {
| '/showcase/'
| '/stats/'
| '/_library/$libraryId/$version'
- | '/_library/ai/adapters'
| '/_library/charts/catalog'
| '/admin/feedback_/$id'
| '/admin/roles/$roleId'
@@ -3785,13 +3773,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LibraryChartsCatalogRouteImport
parentRoute: typeof LibraryRoute
}
- '/_library/ai/adapters': {
- id: '/_library/ai/adapters'
- path: '/ai/adapters'
- fullPath: '/ai/adapters'
- preLoaderRoute: typeof LibraryAiAdaptersRouteImport
- parentRoute: typeof LibraryRoute
- }
'/_library/$libraryId/$version': {
id: '/_library/$libraryId/$version'
path: '/$version'
@@ -4330,7 +4311,6 @@ const LibraryChartsCatalogRouteWithChildren =
interface LibraryRouteChildren {
LibraryLibraryIdRouteRoute: typeof LibraryLibraryIdRouteRouteWithChildren
- LibraryAiAdaptersRoute: typeof LibraryAiAdaptersRoute
LibraryChartsCatalogRoute: typeof LibraryChartsCatalogRouteWithChildren
LibraryAiVersionIndexRoute: typeof LibraryAiVersionIndexRoute
LibraryChartsVersionIndexRoute: typeof LibraryChartsVersionIndexRoute
@@ -4356,7 +4336,6 @@ interface LibraryRouteChildren {
const LibraryRouteChildren: LibraryRouteChildren = {
LibraryLibraryIdRouteRoute: LibraryLibraryIdRouteRouteWithChildren,
- LibraryAiAdaptersRoute: LibraryAiAdaptersRoute,
LibraryChartsCatalogRoute: LibraryChartsCatalogRouteWithChildren,
LibraryAiVersionIndexRoute: LibraryAiVersionIndexRoute,
LibraryChartsVersionIndexRoute: LibraryChartsVersionIndexRoute,
diff --git a/src/routes/_library/ai.adapters.tsx b/src/routes/_library/ai.adapters.tsx
deleted file mode 100644
index fdb0acfd3..000000000
--- a/src/routes/_library/ai.adapters.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router'
-import { useSuspenseQuery } from '@tanstack/react-query'
-import { AiAdapters } from '~/components/ai-adapters/AiAdapters'
-import { docsConfigQueryOptions } from '~/queries/docsConfig'
-import { aiCoverageQueryOptions } from '~/utils/ai-coverage'
-import { seo } from '~/utils/seo'
-
-export const Route = createFileRoute('/_library/ai/adapters')({
- staleTime: 1000 * 60 * 5,
- loader: async ({ context: { queryClient } }) => {
- const [config] = await Promise.all([
- queryClient.ensureQueryData(docsConfigQueryOptions('ai', 'latest')),
- queryClient.ensureQueryData(aiCoverageQueryOptions('latest')),
- ])
- return { config, version: 'latest' }
- },
- head: () => ({
- meta: seo({
- title: 'TanStack AI Adapters',
- description:
- 'Every TanStack AI adapter package and the activities it supports, read from the packages.',
- }),
- }),
- component: AiAdaptersRoute,
-})
-
-function AiAdaptersRoute() {
- const { data: coverage } = useSuspenseQuery(aiCoverageQueryOptions('latest'))
- return
-}
diff --git a/src/utils/ai-coverage.ts b/src/utils/ai-coverage.ts
deleted file mode 100644
index a61f35ad7..000000000
--- a/src/utils/ai-coverage.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import * as v from 'valibot'
-import { createServerFn } from '@tanstack/react-start'
-import { setResponseHeaders } from '@tanstack/react-start/server'
-import { queryOptions } from '@tanstack/react-query'
-import {
- fetchRepoFile,
- isRecoverableGitHubContentError,
-} from './documents.server'
-import { getBranch, getLibrary } from '~/libraries'
-
-// Mirrors scripts/generate-coverage.ts in TanStack/ai.
-const modalitiesSchema = v.object({
- input: v.array(v.string()),
- output: v.array(v.string()),
-})
-
-const coverageSchema = v.object({
- generatedAt: v.string(),
- activities: v.array(v.string()),
- adapters: v.array(
- v.object({
- id: v.string(),
- package: v.string(),
- name: v.string(),
- docs: v.string(),
- note: v.optional(v.string()),
- activities: v.record(v.string(), v.array(v.string())),
- models: v.record(v.string(), modalitiesSchema),
- }),
- ),
-})
-
-export type AiCoverage = v.InferOutput
-export type AiCoverageAdapter = AiCoverage['adapters'][number]
-
-const COVERAGE_FILE = 'adapter-coverage.json'
-
-export const fetchAiCoverage = createServerFn({ method: 'GET' })
- .validator(v.object({ repo: v.string(), branch: v.string() }))
- .handler(async ({ data }): Promise => {
- const { repo, branch } = data
-
- let file: string | null
- try {
- file = await fetchRepoFile(repo, branch, COVERAGE_FILE)
- } catch (error) {
- if (!isRecoverableGitHubContentError(error)) {
- throw error
- }
- return null
- }
-
- if (!file) {
- return null
- }
-
- const parsed = v.safeParse(coverageSchema, JSON.parse(file))
- if (!parsed.success) {
- console.error(JSON.stringify(parsed.issues, null, 2))
- return null
- }
-
- setResponseHeaders(
- new Headers({
- 'Cache-Control': 'public, max-age=0, must-revalidate',
- 'Cloudflare-CDN-Cache-Control':
- 'public, max-age=300, stale-while-revalidate=300',
- // Same tags as the docs so the docs webhook invalidates this too.
- 'Cache-Tag': ['docs:all', 'docs:ai', `docs:ai:branch:${branch}`].join(
- ',',
- ),
- }),
- )
-
- return parsed.output
- })
-
-export function aiCoverageQueryOptions(version: string) {
- const library = getLibrary('ai')
- const branch = getBranch(library, version)
-
- return queryOptions({
- queryKey: ['ai-coverage', library.repo, branch],
- queryFn: () => fetchAiCoverage({ data: { repo: library.repo, branch } }),
- staleTime: 1000 * 60 * 5,
- })
-}
diff --git a/src/utils/docsNavTabs.ts b/src/utils/docsNavTabs.ts
index 3ce556490..0ea4f5e98 100644
--- a/src/utils/docsNavTabs.ts
+++ b/src/utils/docsNavTabs.ts
@@ -18,6 +18,7 @@ export const docsNavTabs: Array<{ id: DocsNavTabId; label: string }> = [
{ id: 'get-started', label: 'Get Started' },
{ id: 'tutorial', label: 'Tutorial' },
{ id: 'guides', label: 'Guides' },
+ // Only shown for libraries whose docs config tags sections with it.
{ id: 'adapters', label: 'Adapters' },
{ id: 'api', label: 'API' },
{ id: 'examples', label: 'Examples' },
@@ -197,15 +198,6 @@ export function getActiveDocsNavTabId({
return 'examples'
}
- // A menu child that names this exact path wins over pattern matches such
- // as Home's `..`, which also matches two-segment library pages.
- for (const group of menuConfig) {
- const exact = group.children.find((child) => child.to === pathname)
- if (exact) {
- return getDocsNavTabId(group, exact)
- }
- }
-
const activeGroup = menuConfig.find((group) =>
group.children.some((child) =>
isChildPathMatch({ childTo: child.to, pathname, relativePathname }),
From 3a685032819f07308a9a8c6e8131b55bfaeb0295 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:15:06 +1000
Subject: [PATCH 10/18] Tighten the AI landing page and make the typesafe model
demo real
Replace the "Who owns what" grid with the AG-UI section, drop the server
section the hero already covers, and trim rail and intro copy. Reorder to
AG-UI, typesafe models, tools, UI, persistence, durability.
The typesafe workbench now shows one narrowed field per model across chat,
image, video, and world models, with values taken from the adapters'
type files. Every section intro and rail item links to its docs page.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 659 ++++++++------------
src/components/landing/LandingPromptBox.tsx | 39 ++
src/components/landing/LibraryLanding.tsx | 25 +-
3 files changed, 318 insertions(+), 405 deletions(-)
create mode 100644 src/components/landing/LandingPromptBox.tsx
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index 813632475..bcef702c5 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -16,7 +16,6 @@ import {
PlugIcon,
RadioIcon,
RobotIcon,
- ScalesIcon,
TerminalIcon,
WaveformIcon,
type Icon,
@@ -25,6 +24,7 @@ import {
import { getLibrary } from '~/libraries'
import { CodeBlock } from '~/components/markdown/CodeBlock'
import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion'
+import { LandingPromptBox } from './LandingPromptBox'
import {
LandingSection,
LandingSectionIntro,
@@ -32,12 +32,8 @@ import {
LibraryLandingShell,
} from './LibraryLanding'
-const aiPrompt = [
- 'Build me a TanStack Start app using TanStack AI as its driver to showcase AI features, if run inside of an existing app then add a single new page and endpoint to showcase the power of TanStack AI.',
- 'Drive the agent loop with chat(): isomorphic tools via toolDefinition().server() / .client(), add at least 1 tool on the server and on the client, use the headless UI features to build the UI, add a tool that needs approval.',
- 'Reach for the rest of the stack only when the task needs it: Code Mode in an isolate for multi-tool orchestration, a sandboxed coding-agent harness, @tanstack/ai-mcp for MCP servers, memoryMiddleware for cross-session recall, @tanstack/ai-persistence for durable threads and resumable streams.',
- 'Never introduce a hosted gateway, a prescribed UI kit, or a provider-specific wire format. Keep provider capabilities honest: model options, tool support, and modality-specific results stay typed at the adapter boundary, and media or realtime primitives appear only where the selected model supports them.',
-].join(' ')
+const aiPrompt =
+ 'Install the agent skills from the skills folder of https://github.com/TanStack/ai for my user, read them, then ask me what AI features I want to build, or suggest some.'
// ponytail: the shared --landing-accent-ink is pure black, which reads badly on the
// orange accent fill. Darken the fill instead and use white text on it.
@@ -49,55 +45,53 @@ export default function AiLanding() {
}
- prompt={aiPrompt}
- promptLabel="Copy AI prompt"
+ beforeActions={}
>
-
+ }
- title="One rule decides every API."
- body="If it is hard to get right and identical in every app, we own it. If it stops fitting the day your app is no longer a prototype, you own it and we hand you typed helpers. Nothing here is a wrapper around a service we run."
+ eyebrow="Open protocol"
+ icon={}
+ title="AG-UI compliant, in both directions."
+ body="The client sends AG-UI requests and consumes AG-UI events, so the agent on the other end is replaceable: point the same client at a Python, Go, or PHP runtime and it keeps working. Bring your own transport."
+ action={
+
+ AG-UI compliance
+
+ }
/>
-
+
-
-
+
+
+ }
- title="One call, one Response, any framework."
- body="chat() takes messages and returns a stream. Turn it into a Response and return it from whatever route you already have. Auth, rate limits, and the deploy target stay in your code, where you can see them."
+ eyebrow="Typesafe models"
+ icon={}
+ title="Typed options for every model."
+ body="Pick a model and TypeScript narrows the fields to what it supports. Input parts for chat. Pixel sizes on one image model and aspect ratio plus resolution on the next. Durations and tiers for video. Resolution for world models. The wrong value fails in the editor, not in production."
+ action={
+
+ Connection adapters
+
+ }
/>
-
-
-
-
-
- }
- title="Your database. Your schema. Two functions."
- body="A framework that owns your tables is great until you need soft delete, archiving, or a column it never imagined. So the core never sees your schema. Load a thread, save a thread, and the transcript, run status, and pending approvals land wherever you point them."
- />
-
-
+
}
- title="A stream survives the reload. The log is yours."
- body="Every chunk is written to a log before it is delivered. Drop the socket, refresh the page, open a second tab, and the client replays from its last offset instead of paying for the model again. Start in memory, move to a hosted log, or write five methods against the store you already run."
+ eyebrow="We handle tools"
+ icon={}
+ title="Define a tool once. Run it on either side."
+ body="One schema gives you the input and output types on the server and the client. The loop calls the tool, pauses for approval when you ask it to, applies the user's edits, and feeds the result back to the model."
+ action={Tools}
/>
-
+
@@ -108,46 +102,41 @@ export default function AiLanding() {
eyebrow="You own the UI"
icon={}
title="Typed parts, honest states, no components to fight."
- body="A message is a list of parts, and every part carries its own lifecycle. Text streams, a tool call moves through input, approval, and result, and an error is a state rather than an exception you missed. Render them yourself or register one component per part type."
+ body="A message is a list of parts, and every part carries its own lifecycle. Render them yourself or register one component per part type."
+ action={UI integrations}
/>
-
- }
- title="Define a tool once. Run it on either side."
- body="One schema gives you the input and output types on the server and the client. The loop calls the tool, pauses for approval when you ask it to, applies the user's edits, and feeds the result back to the model."
- />
-
-
+ }
+ title="Your database. Your schema."
+ body="Persistence is two functions: load a thread and save a thread. The ai-persistence skill ships with the package, so your coding agent can wire them to your tables and ORM in one pass."
+ action={Persistence}
+ />
+
-
-
+
}
- title="The types know which model you picked."
- body="Select a model and TypeScript narrows its options, capabilities, and input modalities. Pass an image to a text-only model and it fails in the editor, not in production. Connect directly to the provider or through the gateway you choose."
+ eyebrow="Durability you can move"
+ icon={}
+ title="Refresh mid-answer and nothing is lost."
+ body="Every chunk is written to a log before it is delivered. Drop the socket or refresh the page and the client replays from its last offset instead of paying for the model again."
+ action={
+
+ Resumable streams
+
+ }
/>
+
-
- }
- title="AG-UI compliant, in both directions."
- body="The client sends AG-UI requests and consumes AG-UI events, with no proprietary stream format and no translation layer in between. That is what makes the agent on the other end replaceable: point the same client at a Python, Go, or PHP AG-UI runtime and it keeps working. The transport is yours too, whether that is SSE, HTTP streams, XHR, RPC, a raw async iterable, or a fetcher you wrote. Nothing to sign up for, no key to hand over, no traffic through us."
- />
-
-
-
}
- title="Images, video, speech, and realtime voice."
+ title="Images, video, speech, voice, and live worlds."
body="The same adapters and the same persistence cover every modality, with progress updates and cost tracking built in."
+ action={Generations}
/>
@@ -179,6 +169,7 @@ export default function AiLanding() {
icon={}
title="See every action on both sides."
body="Every tool call, interrupt, memory recall, and finish reason, on the server and in the client, in one timeline."
+ action={Devtools}
/>
@@ -244,184 +235,6 @@ function CodeTabs({
)
}
-const ownership = {
- handled: [
- {
- label: 'Agent loop',
- body: 'Tool calls, stop conditions, and every model round-trip. Easy to start, wrong in a hundred small ways.',
- },
- {
- label: 'Providers',
- body: 'Every major provider behind one call, each model typed down to its options and modalities.',
- },
- {
- label: 'Durability',
- body: 'A dropped socket, a reload, or a restart replays from a log. The model is never re-run.',
- },
- {
- label: 'Interrupts',
- body: 'A run pauses for a human, then resumes at the exact step with their edits applied.',
- },
- {
- label: 'Sandboxes',
- body: 'Coding agents and Code Mode run in an isolate or a container. Their activity is ordinary events.',
- },
- {
- label: 'Tools',
- body: 'One schema, typed on both ends, executed on the server or the client.',
- },
- ],
- owned: [
- {
- label: 'Server',
- body: 'Any route, any runtime. Your auth check sits next to the call, not behind a config flag.',
- },
- {
- label: 'Persistence',
- body: 'Your database and your schema. Two store functions are the whole contract.',
- },
- {
- label: 'UI',
- body: 'Typed messages, parts, and states. You render them.',
- },
- {
- label: 'Deploy',
- body: 'Your requests, credentials, and data never pass through TanStack.',
- },
- ],
-}
-
-function OwnershipMap() {
- return (
-
- Add a runs store to rejoin a run after a reload and an interrupts store
- to hold an approval for days. Start with memoryPersistence() on the
- server or localStoragePersistence() in the browser, and swap it out
- without touching the route.
-
)
}
@@ -838,66 +645,76 @@ type RailItem = {
detail: string
icon: Icon
label: string
+ to: string
}
const agentStack: Array = [
{
label: 'Code Mode',
detail: '@tanstack/ai-code-mode',
- body: 'You provide a special tool to the LLM provider that allows it to chain tools (functions) into a single executable script and call it in a local or remote isolate, producing results that it further processes. It writes code and calls it.',
+ to: 'code-mode/code-mode',
+ body: 'The model chains your tools into one script and runs it in an isolate, instead of one round-trip per call.',
icon: CodeIcon,
},
{
label: 'Coding-agent harnesses',
detail: '@tanstack/ai-sandbox',
- body: 'Run Claude Code, Codex, OpenCode, Grok Build, or any ACP agent as a chat backend, inside a local process, Docker, Daytona, Vercel, Sprites, or Cloudflare sandbox. Their tool activity streams back as AG-UI events your UI already renders.',
+ to: 'sandbox/overview',
+ body: 'Run Claude Code, Codex, or any ACP agent as a chat backend in a local process or a sandbox. Its activity streams back as events your UI already renders.',
icon: TerminalIcon,
},
{
label: 'MCP + MCP Apps',
detail: '@tanstack/ai-mcp',
- body: 'A host-side MCP client with a type-generating CLI, provider-routed mcpTool(), and interactive ui:// widgets rendered from tool results across multiple servers.',
+ to: 'tools/mcp',
+ body: 'A typed MCP client with a CLI that generates the types, plus interactive widgets rendered from tool results.',
icon: CubeIcon,
},
{
label: 'Memory + compaction',
detail: '@tanstack/ai-memory · @tanstack/ai-compaction',
- body: 'memoryMiddleware recalls across sessions through Redis, mem0, Honcho, or Hindsight adapters. Compaction keeps long threads inside the model window so the agent does not lose the thread as context grows.',
+ to: 'memory/overview',
+ body: 'Recall across sessions through Redis, mem0, Honcho, or Hindsight. Compaction keeps long threads inside the model window.',
icon: DatabaseIcon,
},
- {
- label: 'Durability + persistence',
- detail: '@tanstack/ai-persistence · @tanstack/ai-durable-stream',
- body: 'Persistence keeps an authoritative server thread, resumes a stream through a dropped connection, and survives a reload. Durability lets a run continue after a process restart.',
- icon: HardDrivesIcon,
- },
]
const modalities: Array = [
{
label: 'Text, objects, reasoning',
detail: 'chat · outputSchema · summarize',
- body: 'Generate an output from an AI that matches your validation schema exactly using structured output.',
+ to: 'chat/structured-outputs',
+ body: 'Structured output that matches your schema exactly.',
icon: RobotIcon,
},
{
label: 'Speech, transcription, music',
detail: 'generateSpeech · generateTranscription · generateAudio',
- body: 'Six speech formats with speed control, transcription with word timestamps and diarization, plus music and sound effects.',
+ to: 'media/text-to-speech',
+ body: 'Transcription with word timestamps and diarization, plus music and sound effects.',
icon: MicrophoneIcon,
},
{
label: 'Realtime voice',
detail: 'openaiRealtimeToken · RealtimeClient',
+ to: 'media/realtime-chat',
body: 'OpenAI, Grok, and ElevenLabs with VAD modes and tool calling inside a live session.',
icon: WaveformIcon,
},
{
label: 'Images + video',
detail: 'generateImage · generateVideo',
- body: 'Generate images and videos, edit existing generations and show progress updates to your users with ease.',
+ to: 'media/video-generation',
+ body: 'Generate, edit, and stream progress to the user.',
icon: RadioIcon,
},
+ {
+ label: 'World models + live video',
+ detail: 'generateWorld · generateLiveVideo',
+ to: 'media/world-generation',
+ body: 'Mint a session on the server and stream an explorable world or live video into the browser over WebRTC.',
+ icon: CubeIcon,
+ },
]
const devtoolsHooks = [
@@ -1030,7 +847,11 @@ function FeatureRail({ items }: { items: Array }) {
-
{item.label}
+
+
+ {item.label}
+
+
{item.detail}
@@ -1055,6 +876,38 @@ const startingPoints = [
{ label: 'Compare with Vercel AI SDK', to: 'comparison/vercel-ai-sdk' },
]
+function DocsLink({
+ children,
+ plain = false,
+ to,
+}: {
+ children: React.ReactNode
+ plain?: boolean
+ to: string
+}) {
+ const { version } = useParams({ strict: false })
+ const library = getLibrary('ai')
+
+ return (
+
+ {children}
+ {plain ? null : }
+
+ )
+}
+
function StartingPoints() {
const { version } = useParams({ strict: false })
const library = getLibrary('ai')
@@ -1434,36 +1287,118 @@ function WriteOnceHero() {
)
}
+// Each model shows one field the types narrow per model. `picked` is what the
+// snippet passes; when it is not in `allowed` the snippet shows a type error.
const compilerModels = [
{
- name: 'gpt-5.5',
- adapter: "openaiText('gpt-5.5')",
+ name: 'gpt-6-astra',
pkg: '@tanstack/ai-openai',
- input: ['text', 'image', 'document'],
+ fn: 'chat',
+ adapter: "openaiText('gpt-6-astra')",
+ setup: [],
+ line: (value: string) =>
+ `messages: [{ role: 'user', content: [{ type: '${value}', source: receiptUrl }] }]`,
+ field: 'input',
+ allowed: ['text', 'image'],
+ picked: 'image',
+ note: 'Input parts are typed per model.',
},
{
- name: 'claude-sonnet-4-5',
- adapter: "anthropicText('claude-sonnet-4-5')",
+ name: 'claude-fable-5-1',
pkg: '@tanstack/ai-anthropic',
- input: ['text', 'image', 'document'],
+ fn: 'chat',
+ adapter: "anthropicText('claude-fable-5-1')",
+ setup: [],
+ line: (value: string) =>
+ `messages: [{ role: 'user', content: [{ type: '${value}', source: invoicePdf }] }]`,
+ field: 'input',
+ allowed: ['text', 'image', 'document'],
+ picked: 'document',
+ note: 'PDFs go in as document parts on models that read them.',
},
{
- name: 'gemini-3-flash-preview',
- adapter: "geminiText('gemini-3-flash-preview')",
- pkg: '@tanstack/ai-gemini',
- input: ['text', 'image', 'audio', 'video', 'document'],
+ name: 'llama-3.3-70b-versatile',
+ pkg: '@tanstack/ai-groq',
+ fn: 'chat',
+ adapter: "groqText('llama-3.3-70b-versatile')",
+ setup: [],
+ line: (value: string) =>
+ `messages: [{ role: 'user', content: [{ type: '${value}', source: receiptUrl }] }]`,
+ field: 'input',
+ allowed: ['text'],
+ picked: 'image',
+ note: 'Text-only model, so the image part fails to type.',
},
{
- name: 'gpt-4o-audio',
- adapter: "openaiText('gpt-4o-audio')",
+ name: 'gpt-image-2',
pkg: '@tanstack/ai-openai',
- input: ['text', 'audio'],
+ fn: 'generateImage',
+ adapter: "openaiImage('gpt-image-2')",
+ setup: ["prompt: 'A neon city at night'"],
+ line: (value: string) => `size: '${value}'`,
+ field: 'size',
+ allowed: ['1024x1024', '1536x1024', '1024x1536', 'auto'],
+ picked: '1536x1024',
+ note: 'OpenAI sizes are pixels, width by height.',
},
{
- name: 'llama-3.3-70b-versatile',
- adapter: "groqText('llama-3.3-70b-versatile')",
- pkg: '@tanstack/ai-groq',
- input: ['text'],
+ name: 'grok-imagine-image-2.0',
+ pkg: '@tanstack/ai-grok',
+ fn: 'generateImage',
+ adapter: "grokImage('grok-imagine-image-2.0')",
+ setup: ["prompt: 'A neon city at night'"],
+ line: (value: string) => `size: '${value}'`,
+ field: 'size',
+ allowed: ['1:1', '16:9', '9:16', '3:2', 'auto', '16:9_1k', '16:9_2k'],
+ picked: '16:9_2k',
+ note: 'Grok sizes are an aspect ratio, or ratio_resolution. Fourteen ratios at 1k or 2k, all typed.',
+ },
+ {
+ name: 'gemini-omni-1.1-flash',
+ pkg: '@tanstack/ai-gemini',
+ fn: 'generateVideo',
+ adapter: "geminiVideo('gemini-omni-1.1-flash')",
+ setup: [
+ 'prompt: [',
+ " { type: 'image', source: { type: 'url', value: firstFrame } },",
+ " { type: 'text', content: 'Slow push in, rain on neon' },",
+ ']',
+ ],
+ line: (value: string) => `size: '${value}'`,
+ field: 'size',
+ allowed: ['16:9', '9:16', '16:9_720p', '16:9_1080p', '16:9_4k'],
+ picked: '16:9_4k',
+ note: 'Image and video parts in the prompt. Same ratio_resolution template, with tiers up to 4k, and any duration from 3 to 10 seconds.',
+ },
+ {
+ name: 'dreamina-seedance-2-5-260628',
+ pkg: '@tanstack/ai-byteplus',
+ fn: 'generateVideo',
+ adapter: "byteplusVideo('dreamina-seedance-2-5-260628')",
+ setup: [
+ 'prompt: [',
+ " { type: 'image', role: 'reference', source: { type: 'url', value: heroShot } },",
+ " { type: 'audio', source: { type: 'url', value: beatUrl } },",
+ " { type: 'text', content: 'Cut on the beat, keep the outfit' },",
+ ']',
+ ],
+ line: (value: string) => `size: '${value}'`,
+ field: 'size',
+ allowed: ['16:9', '9:16', '1:1', '21:9', '16:9_720p', '16:9_1080p'],
+ picked: '16:9_4k',
+ note: 'Reference image, video, and audio parts in the prompt. Seedance 2.5 stops at 1080p. The 4k tier only exists on Seedance 2.0, and the types know that.',
+ },
+ {
+ name: 'visko-orbis-stable',
+ pkg: '@tanstack/ai-reactor',
+ fn: 'generateWorld',
+ adapter: "reactorWorld('visko-orbis-stable')",
+ setup: ["prompt: 'A neon city at night'"],
+ line: (value: string) => `modelOptions: { resolution: '${value}' }`,
+ field: 'resolution',
+ allowed: ['1080p', '2k', '4k'],
+ picked: '4k',
+ note: 'World models stream over WebRTC. Resolution is a delivery tier.',
},
]
@@ -1483,11 +1418,12 @@ function Str({ children }: { children: React.ReactNode }) {
function ProviderWorkbench() {
const [activeIndex, setActiveIndex] = React.useState(0)
const model = compilerModels[activeIndex] ?? compilerModels[0]
- const acceptsImage = model.input.includes('image')
+ const valid = model.allowed.includes(model.picked)
+ const adapterName = model.adapter.split('(')[0]
return (
-
- ✓ no errors. {model.name} accepts image input.
+ ✓ no errors. '{model.picked}' is a valid {model.field} for{' '}
+ {model.name}.
) : (
- error TS2322: Type 'ImagePart' is not assignable to type
- 'TextPart'. {model.name} accepts {model.input.join(', ')} input
- only.
+ error TS2322: Type '{model.picked}' is not assignable to type '
+ {model.allowed.join(' | ')}'.
)
}
From ead5a1284b1bc2f5819bab783b5e9d353eae61d8 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:24:08 +1000
Subject: [PATCH 11/18] Fix the typesafe workbench on narrow screens
Let the snippet column shrink so the code scrolls and the value chips
wrap, put the heading before the graphic on mobile, and drop the doubled
commas on prompt part lines.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 20 ++++++++++++--------
1 file changed, 12 insertions(+), 8 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index bcef702c5..9f45df0f9 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -67,7 +67,6 @@ export default function AiLanding() {
@@ -97,7 +99,6 @@ export default function AiLanding() {
- }
@@ -105,6 +106,9 @@ export default function AiLanding() {
body="A message is a list of parts, and every part carries its own lifecycle. Render them yourself or register one component per part type."
action={UI integrations}
/>
+
import {'{ '}
From a9777269a663791e68be8bb752cf4f630a25db96 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:30:16 +1000
Subject: [PATCH 12/18] Address review comments on the AI landing page
Give CodeTabs toggle-group semantics, key code samples by name so the
copied state does not carry across samples that share a filename, import
chat in the durability samples, hide the looping message parts animation
from assistive technology, and match the Seedance note to its snippet.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 41 +++++++++++++++++++---------
1 file changed, 28 insertions(+), 13 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index 9f45df0f9..f7ba68cc8 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -212,15 +212,15 @@ function CodeTabs({
{samples.map((item, index) => (
-
+
+ A message is a list of parts. A thinking part, then a tool call that
+ moves from awaiting input through approval to complete, then the tool
+ result and the streamed text reply.
+
From 888c2e73ec516907553f704684e3b84c259c7206 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 13:13:24 +1000
Subject: [PATCH 14/18] Match the AI catalog copy to the landing hero and drop
the unused ai.tsx
The catalog description feeds the landing page meta description and the
tagline feeds the library grid card. Nothing imported aiProject and no
component renders featureHighlights.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/libraries/ai.tsx | 51 --------------------------------------
src/libraries/libraries.ts | 5 ++--
2 files changed, 3 insertions(+), 53 deletions(-)
delete mode 100644 src/libraries/ai.tsx
diff --git a/src/libraries/ai.tsx b/src/libraries/ai.tsx
deleted file mode 100644
index f14a3ccbb..000000000
--- a/src/libraries/ai.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Library } from '.'
-import { PlugIcon, LightningIcon, GearIcon } from '@phosphor-icons/react'
-import { twMerge } from 'tailwind-merge'
-import { ai } from './libraries'
-
-const textStyles = `text-category-data`
-
-export const aiProject = {
- ...ai,
- description: `TanStack AI gives you composable building blocks for everything you should not write yourself: the agent loop, provider adapters, durability, interrupts, sandboxes, and tools. It leaves you everything a one-size-fits-all framework gets wrong past the prototype: your server, your database, your UI. Typed end to end, AG-UI native, and no TanStack service in the request path.`,
- latestBranch: 'main',
- defaultDocs: 'getting-started/overview',
- featureHighlights: [
- {
- title: "We Build What You Shouldn't",
- icon: ,
- description: (
-
- chat() drives the agent loop: typed tools that run on the
- server or the client, stop strategies, interrupts that pause a run for
- a human and resume at the exact step, and a durability log that
- survives a dropped socket or a reload without re-running the model.
-
- ),
- },
- {
- title: 'You Own What Outgrows a Framework',
- icon: ,
- description: (
-
- Your route, your database, your UI. Persistence is two store functions
- against your own schema. The server is one call and a Response in any
- framework. Your requests, credentials, and data never pass through
- TanStack.
-
- Every major provider behind one call, each model typed down to its
- options and modalities. A framework-free core with React, Vue, Solid,
- Svelte, Preact, Angular, React Native, and Octane bindings, all
- speaking native AG-UI over the transport you choose.
-
- ),
- },
- ],
-} satisfies Library
diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts
index 3510d3430..1c7982a3e 100644
--- a/src/libraries/libraries.ts
+++ b/src/libraries/libraries.ts
@@ -691,9 +691,10 @@ export const ai: LibrarySlim = {
...categoryStyles.data,
name: 'TanStack AI',
to: '/ai/latest',
- tagline: 'Composable AI building blocks. Your server, database, and UI',
+ tagline:
+ 'AI building blocks for TypeScript. We build the hard parts, you keep the stack.',
description:
- 'TanStack AI gives you composable building blocks for everything you should not write yourself: the agent loop, provider adapters, durability, interrupts, sandboxes, and tools. It leaves you everything a one-size-fits-all framework gets wrong past the prototype: your server, your database, your UI. Typed end to end, AG-UI native, and no TanStack service in the request path.',
+ 'TanStack AI is a TypeScript library for building AI features and agents. It ships the agent loop, provider adapters, durability, interrupts, sandboxes, and tools, and plugs into the server, database, and UI you already have.',
badge: 'RC',
repo: 'tanstack/ai',
frameworks: [
From 116aa0881334d06d415329999056cca9ca13e644 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 13:20:48 +1000
Subject: [PATCH 15/18] Tighten landing copy and match the Omni note to its
snippet
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index cd5d9875c..26590ec0c 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -90,7 +90,7 @@ export default function AiLanding() {
eyebrow="We handle tools"
icon={}
title="Define a tool once. Run it on either side."
- body="One schema gives you the input and output types on the server and the client. The loop calls the tool, pauses for approval when you ask it to, applies the user's edits, and feeds the result back to the model."
+ body="One schema gives you the input and output types on the server and the client. The loop calls the tool, waits for approval when asked to, applies user's edits, and feeds the result back to the model."
action={Tools}
/>
@@ -118,7 +118,7 @@ export default function AiLanding() {
eyebrow="You own persistence"
icon={}
title="Your database. Your schema."
- body="Persistence is two functions: load a thread and save a thread. The ai-persistence skill ships with the package, so your coding agent can wire them to your tables and ORM in one pass."
+ body="Persistence is two functions: loading and saving a thread. With the ai-persistence skill shipped with the package, your coding agent can wire them to your tables and ORM in one pass."
action={Persistence}
/>
@@ -130,7 +130,7 @@ export default function AiLanding() {
eyebrow="Durability you can move"
icon={}
title="Refresh mid-answer and nothing is lost."
- body="Every chunk is written to a log before it is delivered. Drop the socket or refresh the page and the client replays from its last offset instead of paying for the model again."
+ body="Every chunk is written to a log before it is delivered. Drop the socket or refresh the page and the client replays from the last offset instead of losing the model's answer."
action={
Resumable streams
@@ -147,7 +147,7 @@ export default function AiLanding() {
eyebrow="We handle the hard parts"
icon={}
title="Sandboxes, Code Mode, MCP, memory, compaction."
- body="Each one is a separate package with the same shape as the core. Reach for it when the task needs it, and leave it out of the bundle when it does not."
+ body="With each feature as its own package, load what the task needs and leave out the rest."
/>
@@ -1387,7 +1387,7 @@ const compilerModels = [
field: 'size',
allowed: ['16:9', '9:16', '16:9_720p', '16:9_1080p', '16:9_4k'],
picked: '16:9_4k',
- note: 'Image and video parts in the prompt. Same ratio_resolution template, with tiers up to 4k, and any duration from 3 to 10 seconds.',
+ note: 'A start frame image plus text, and video parts too. Same ratio_resolution template, with tiers up to 4k, and any duration from 3 to 10 seconds.',
},
{
name: 'dreamina-seedance-2-5-260628',
From 9b45eded0c420d0d9f65223b1f8c3f8b1863af3d Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 18:54:32 +1000
Subject: [PATCH 16/18] Make the AI landing graphics interactive
- Slow the runs-anywhere hero cycle to 6s so a snippet can be read
- Add a shimmering "break me" toggle to the typesafe workbench that swaps
in a value the types reject, with a green "fix me" to undo it
- Step message.parts through every part and tool-call state, with each
row and lifecycle chip clickable, and show the matching render code
under a createChatHook on/off switch
- Make the devtools hooks clickable with a run timeline per hook, and
replace the non-existent useObject with useChat + outputSchema
- Rewrite the message-parts section copy
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 499 +++++++++++++++++++++------
src/styles/app.css | 11 +
2 files changed, 410 insertions(+), 100 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index 26590ec0c..36dc928dc 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -90,7 +90,7 @@ export default function AiLanding() {
eyebrow="We handle tools"
icon={}
title="Define a tool once. Run it on either side."
- body="One schema gives you the input and output types on the server and the client. The loop calls the tool, waits for approval when asked to, applies user's edits, and feeds the result back to the model."
+ body="One schema gives you the input and output types on the server and the client. The loop calls the tool, waits for approval when asked, applies the user's edits, and feeds the result back to the model."
action={Tools}
/>
@@ -102,8 +102,8 @@ export default function AiLanding() {
}
- title="Typed parts, honest states, no components to fight."
- body="A message is a list of parts, and every part carries its own lifecycle. Render them yourself or register one component per part type."
+ title="Messages are parts. Render however you like."
+ body="Text, thinking, tool calls and results all arrive as typed parts with their own state. Loop over the parts and render each one, or hand a component per part type to createChatHook and it picks the right one for you."
action={UI integrations}
/>
@@ -364,35 +364,184 @@ const toolCallStates = [
'complete',
] as const
+type ToolCallState = (typeof toolCallStates)[number]
+
+// Both snippets stay put while the cycle runs; only the highlighted line
+// moves. A line tagged with a part lights up when that part is active, and a
+// line tagged with a tool state only when the tool call is in that state.
+type CodeLine = {
+ text: string
+ part?: 'thinking' | 'tool-call' | 'tool-result' | 'text'
+ toolState?: ToolCallState
+}
+
+const toolBranches: Array = [
+ {
+ text: "if (part.state === 'awaiting-input') return ",
+ part: 'tool-call',
+ toolState: 'awaiting-input',
+ },
+ {
+ text: "if (part.state === 'input-streaming') return ",
+ part: 'tool-call',
+ toolState: 'input-streaming',
+ },
+ {
+ text: "if (part.state === 'input-complete') return
A message is a list of parts. A thinking part, then a tool call that
moves from awaiting input through approval to complete, then the tool
- result and the streamed text reply.
+ result and the streamed text reply. Below the list, the component
+ registered for the active part.
@@ -1520,12 +1819,12 @@ function ProviderWorkbench() {
{valid ? (
- ✓ no errors. '{model.picked}' is a valid {model.field} for{' '}
+ ✓ no errors. '{picked}' is a valid {model.field} for{' '}
{model.name}.
) : (
- error TS2322: Type '{model.picked}' is not assignable to type '
+ error TS2322: Type '{picked}' is not assignable to type '
{model.allowed.join(' | ')}'.
)}
diff --git a/src/styles/app.css b/src/styles/app.css
index 90b86ca4c..10926a505 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -87,6 +87,17 @@ html.theme-switching *::after {
--color-gray-800: #332d24;
--color-gray-900: #201b15;
--color-gray-950: #111111;
+
+ --animate-shimmer: shimmer 2.4s ease-in-out infinite;
+ @keyframes shimmer {
+ 0%,
+ 40% {
+ transform: translateX(-150%);
+ }
+ 100% {
+ transform: translateX(250%);
+ }
+ }
}
/* Keep neutral grays stable before client CSS injection reorders Tailwind layers. */
From fd87ff5bc72de51f5f8b9d8c20d3ffd132916255 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Wed, 9 Sep 2026 19:07:55 +1000
Subject: [PATCH 17/18] Make the persistence backends clickable
Each server backend swaps the two message-store lines in persistence.ts
for that database's real load and save calls. localStorage and IndexedDB
are client adapters, so they switch the window to the useChat call with
the matching persistence adapter instead.
Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX
---
src/components/landing/AiLanding.tsx | 130 ++++++++++++++++++++-------
1 file changed, 100 insertions(+), 30 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index 36dc928dc..bc8949a24 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -239,53 +239,123 @@ function CodeTabs({
)
}
-const persistenceContract = `import { defineAIPersistence, defineMessageStore } from '@tanstack/ai-persistence'
-import { db } from './db'
+// Every server backend fills the same two methods. Only those lines change.
+// localStorage and IndexedDB are client adapters with no server package, so
+// they swap the whole window for the useChat call.
+const persistenceStores: Array<
+ | { name: string; load: string; save: string }
+ | { name: string; client: string }
+> = [
+ {
+ name: 'Postgres',
+ load: 'sql`select messages from threads where id = ${threadId}`.then((rows) => rows[0]?.messages ?? [])',
+ save: 'sql`insert into threads (id, messages) values (${threadId}, ${sql.json(messages)})\n on conflict (id) do update set messages = excluded.messages`',
+ },
+ {
+ name: 'MySQL',
+ load: "pool.query('select messages from threads where id = ?', [threadId]).then(([rows]) => rows[0]?.messages ?? [])",
+ save: "pool.query('replace into threads (id, messages) values (?, ?)', [threadId, JSON.stringify(messages)])",
+ },
+ {
+ name: 'SQLite',
+ load: "JSON.parse(db.prepare('select messages from threads where id = ?').get(threadId)?.messages ?? '[]')",
+ save: "db.prepare('insert or replace into threads values (?, ?)').run(threadId, JSON.stringify(messages))",
+ },
+ {
+ name: 'MongoDB',
+ load: 'threads.findOne({ _id: threadId }).then((doc) => doc?.messages ?? [])',
+ save: 'threads.updateOne({ _id: threadId }, { $set: { messages } }, { upsert: true })',
+ },
+ {
+ name: 'Cloudflare D1',
+ load: "env.DB.prepare('select messages from threads where id = ?').bind(threadId).first('messages').then((json) => JSON.parse(json ?? '[]'))",
+ save: "env.DB.prepare('insert or replace into threads values (?, ?)').bind(threadId, JSON.stringify(messages)).run()",
+ },
+ {
+ name: 'Redis',
+ load: "redis.get(`thread:${threadId}`).then((json) => JSON.parse(json ?? '[]'))",
+ save: 'redis.set(`thread:${threadId}`, JSON.stringify(messages))',
+ },
+ {
+ name: 'Drizzle',
+ load: 'db.select().from(threads).where(eq(threads.id, threadId)).then((rows) => rows[0]?.messages ?? [])',
+ save: 'db.insert(threads).values({ id: threadId, messages }).onConflictDoUpdate({ target: threads.id, set: { messages } })',
+ },
+ {
+ name: 'Prisma',
+ load: 'prisma.thread.findUnique({ where: { id: threadId } }).then((row) => row?.messages ?? [])',
+ save: 'prisma.thread.upsert({ where: { id: threadId }, create: { id: threadId, messages }, update: { messages } })',
+ },
+ { name: 'localStorage', client: 'localStoragePersistence' },
+ { name: 'IndexedDB', client: 'indexedDBPersistence' },
+]
+
+function persistenceSnippet(store: (typeof persistenceStores)[number]) {
+ if ('client' in store) {
+ return {
+ file: 'chat.tsx',
+ code: `import { useChat, fetchServerSentEvents, ${store.client} } from '@tanstack/ai-react'
+
+// No server package. The transcript lives in the browser and survives a reload.
+const { messages, sendMessage } = useChat({
+ threadId: 'support-chat',
+ connection: fetchServerSentEvents('/api/chat'),
+ persistence: ${store.client}(),
+})`,
+ }
+ }
+ return {
+ file: 'persistence.ts',
+ code: `import { defineAIPersistence, defineMessageStore } from '@tanstack/ai-persistence'
// The whole contract. Your tables, your columns, your types.
export const persistence = defineAIPersistence({
stores: {
messages: defineMessageStore({
- loadThread: (threadId) => db.threads.messages(threadId),
- saveThread: (threadId, messages) => db.threads.save(threadId, messages),
+ loadThread: (threadId) =>
+ ${store.load},
+ saveThread: async (threadId, messages) => {
+ await ${store.save}
+ },
}),
},
})
-// chat({ ..., middleware: [withPersistence(persistence)] })`
-
-const persistenceStores = [
- 'Postgres',
- 'MySQL',
- 'SQLite',
- 'MongoDB',
- 'Cloudflare D1',
- 'Redis',
- 'Drizzle',
- 'Prisma',
- 'localStorage',
- 'IndexedDB',
-]
+// chat({ ..., middleware: [withPersistence(persistence)] })`,
+ }
+}
function PersistenceContract() {
+ const [activeIndex, setActiveIndex] = React.useState(0)
+ const store = persistenceStores[activeIndex] ?? persistenceStores[0]
+ const snippet = persistenceSnippet(store)
+
return (
-
- {persistenceStores.map((store) => (
-
(
+
+ {item.name}
+
))}
-
-
-
- {persistenceContract}
+
+
+
+ {snippet.code}
From ebdb531c2c2990082193f0d879b98efcb29abd78 Mon Sep 17 00:00:00 2001
From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com>
Date: Thu, 10 Sep 2026 17:59:46 +1000
Subject: [PATCH 18/18] Tweak the AI landing headline and start-here title
---
src/components/landing/AiLanding.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx
index bc8949a24..daf8bcac4 100644
--- a/src/components/landing/AiLanding.tsx
+++ b/src/components/landing/AiLanding.tsx
@@ -44,7 +44,7 @@ export default function AiLanding() {
return (
}
beforeActions={}
@@ -184,7 +184,7 @@ export default function AiLanding() {
centered
eyebrow="Start here"
icon={}
- title="Pick the page that matches your next hour."
+ title="Choose what you want to do next."
body="Each one is a short guide with copyable code, not a tour."
/>