@tanstack/ai-client posts an AG-UI RunAgentInput body. Extra client fields arrive in forwardedProps, not body.data.
On the client, useChat messages, thinking, tools, and approvals keep working. The breaking change is on the HTTP wire and in a few option names. In 1.0, the old body client option and the data wire field are gone. For every other 1.0 change, see Upgrade to 1.0.
| Surface | 0.x | 1.0 |
|---|---|---|
| Client option (useChat, ChatClient) | body: { ... } | forwardedProps: { ... } |
| Server wire field | body.data.X | body.forwardedProps.X, or params.forwardedProps.X from chatParamsFromRequest |
| Server chat() option | conversationId | threadId (or use params.threadId from chatParamsFromRequest) |
0.x accepted both names. 1.0 accepts only the new names.
useChat({
connection: fetchServerSentEvents('/api/chat'),
- body: { provider: 'openai', model: 'gpt-6.1-sol' },
+ forwardedProps: { provider: 'openai', model: 'gpt-6.1-sol' },
})On Svelte, updateBody is now updateForwardedProps.
npx jscodeshift \
--parser=tsx \
-t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \
"src/**/*.{ts,tsx}"To see the changes before the codemod writes them, add --dry --print.
The codemod renames body to forwardedProps in useChat({ body }), new ChatClient({ body }), and updateOptions({ body }). It also renames Svelte updateBody(...) to updateForwardedProps(...), and chat({ conversationId }) to chat({ threadId }).
It changes a file only when the file imports these names from a @tanstack/ai* package. For the full transform list, the conflict rules, and the limits, see codemods/ag-ui-compliance/README.md.
Note
The codemod does not change server reads of body.data.X. A syntax-only codemod cannot tell a TanStack AI route handler from other code. Change these reads by hand, with the tiers below.
Pick the tier that matches the features you use. Most servers fit Tier 1.
Keep reading body.messages, and pass the array to chat(). chat() accepts a mixed UIMessage | ModelMessage array, and it handles the AG-UI message shapes for you.
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { serverTools } from './tools'
export async function POST(req: Request) {
const body = await req.json()
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: body.messages, // AG-UI mixed shape. Pass it as it is.
threadId: body.threadId, // The client sends it at the top level.
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}If your endpoint reads body.data.X, change it to body.forwardedProps.X (or params.forwardedProps.X in Tier 2). A 1.0 client does not send data.
Use chatParamsFromRequest when you want one of these:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { serverTools } from './tools'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}chatParamsFromRequest reads req.json() and validates the body against the AG-UI RunAgentInput contract. If the body is not valid, it throws a 400 Response. TanStack Start, SolidStart, Remix, and React Router 7 send that response to the client for you.
Note
Next.js Route Handlers, SvelteKit, Hono, and raw Node do not handle a thrown Response. In these frameworks, wrap the call in try/catch and return the caught Response. Or call await chatParamsFromRequestBody(await req.json()) and handle the error yourself.
This tier is optional. It is a convenience, not a migration step.
mergeAgentTools takes the tools that the client declares in the request (RunAgentInput.tools) and adds them to the server tools for this one request. The only change from Tier 2 is the tools line:
import {
chat,
chatParamsFromRequest,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { serverTools } from './tools'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
// mergeAgentTools returns a plain array. Pass it to `tools`.
tools: mergeAgentTools(serverTools, params.tools),
})
return toServerSentEventsResponse(stream)
}Use this tier only if the client must decide which tools to advertise, for example when the client shows different tools in each session.
You do not need it for client tools. A client tool in the static tools array of your server still works, also when it has no .server() implementation. The runtime treats a tool with no execute as a client tool, from a static array or from mergeAgentTools.
Warning
Merging lets the client define part of your tool list. params.tools comes from the client, so a bad client can send any name, description, or parameters, and the model sees them.
The safe default is to register your tool definitions in the static tools array of the server, and not to call mergeAgentTools. Then the model never sees the tools that a client declares. Use mergeAgentTools only when you trust the client.
This section is for Tier 2 and Tier 3. You get forwardedProps only from chatParamsFromRequest or chatParamsFromRequestBody.
forwardedProps is JSON that the client controls. Do not spread it into chat({...}):
// UNSAFE: a client can replace the adapter, the model, the tools, the system prompts, and more
chat({
adapter: openaiText('gpt-6.1-sol'),
...params,
...params.forwardedProps,
})Read each field that you allow, and check its type:
import {
chat,
chatParamsFromRequest,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { serverTools } from './tools'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
// SAFE: an explicit allowlist. Sampling options go in modelOptions, under
// the key names of the provider (OpenAI: temperature, max_output_tokens).
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
tools: mergeAgentTools(serverTools, params.tools),
modelOptions: {
temperature:
typeof params.forwardedProps.temperature === 'number'
? params.forwardedProps.temperature
: undefined,
max_output_tokens:
typeof params.forwardedProps.maxTokens === 'number'
? params.forwardedProps.maxTokens
: undefined,
},
})
return toServerSentEventsResponse(stream)
}Your server reads forwardedProps (or params.forwardedProps), no code reads body.data, and a chat message streams a reply.
Old wire shape:
{
"messages": [...],
"data": {...}
}New wire shape:
{
"threadId": "thread-...",
"runId": "run-...",
"state": {},
"messages": [...],
"tools": [...],
"context": [],
"forwardedProps": {...}
}The messages array on the wire is spec AG-UI:
The tools that you pass to useChat({ tools }) go to the server in the request body.
This is a real behavior change, so upgrade @tanstack/ai and @tanstack/ai-client together.
Wire events keep the spec fields and add metadata. The TanStack extras are in metadata.tanstack. The public StreamChunk type is spec-only.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "RUN_FINISHED") {
console.log(chunk.usage);
console.log(chunk.metadata?.tanstack?.finishReason);
}
}For the for await loop, see Stream Events. For onUsage, see Middleware.
threadId is the AG-UI name for a stable conversation id. It connects the client and server devtools events. 1.0 removes conversationId from chat() and from ChatMiddlewareContext.
The client sends threadId as a top-level AG-UI field. It does not put conversationId in forwardedProps.
The codemod renames the conversationId key, but it does not change the value. Check the value after the codemod runs:
// Before: explicit conversationId plumbing
const params = await chatParamsFromRequest(req)
chat({
messages: params.messages,
conversationId: params.forwardedProps.conversationId,
})
// After: forward the AG-UI threadId
const params = await chatParamsFromRequest(req)
chat({ messages: params.messages, threadId: params.threadId })Pass threadId to the ChatClient constructor. It is required:
import { ChatClient, fetchServerSentEvents } from '@tanstack/ai-client'
const client = new ChatClient({
threadId: 'persistent-thread-from-storage',
connection: fetchServerSentEvents('/api/chat'),
})The client does not make a threadId. The one you pass stays the same for the life of the ChatClient instance. Each send gets a new runId.
chat({ context }) is the typed runtime context for tools and middleware. It is a different thing from the AG-UI RunAgentInput.context, and protocol fields do not fill it.
If a client value must reach your server tools or middleware, read it from forwardedProps, check it, and build the context yourself:
import {
chat,
chatParamsFromRequest,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { serverTools } from './tools'
import { session, defaultTenantId, req } from './context'
const params = await chatParamsFromRequest(req)
const tenantId =
typeof params.forwardedProps.tenantId === 'string'
? params.forwardedProps.tenantId
: defaultTenantId
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: params.messages,
threadId: params.threadId,
tools: serverTools,
context: {
userId: session.user.id,
tenantId,
},
})When a @tanstack/ai-client request goes to an AG-UI server that is not TanStack AI:
A spec AG-UI RunAgentInput body (with no TanStack parts field) works end to end:
The AG-UI compliance release moved @tanstack/ai to @ag-ui/core@0.1.1-canary.beta.0. In 1.0, @tanstack/ai depends on @ag-ui/core@1.0.0. If your code imports AG-UI types that @tanstack/ai re-exports, small type changes can be necessary.
@ag-ui/core lists zod as an optional peer, not as a dependency. @tanstack/ai does not use zod at runtime. So an install of @tanstack/ai does not install zod for you.
chatParamsFromRequest and chatParamsFromRequestBody validate the RunAgentInput contract with their own structural checks, not with zod. These things do not change:
The error message names the field that is wrong, for example:
Request body is not a valid AG-UI RunAgentInput. ... Validation errors: messages[1].content must be a stringYou can still define tools with zod. If your project used zod without a dependency entry (from the transitive copy), add zod:
npm i zodpnpm add zodyarn add zodbun add zodIf you define tools with a different Standard Schema library (ArkType, Valibot), you can remove zod.