TL;DR: This release is fully backward compatible. Upgrade @tanstack/ai and @tanstack/ai-client together and existing code keeps working — both the legacy body client option and the legacy data server-side wire field continue to function unchanged. The HTTP wire format gained AG-UI RunAgentInput fields (threadId, runId, tools, forwardedProps, etc.) for full AG-UI compliance, and the legacy fields are emitted alongside them as a deprecation bridge. New helpers (chatParamsFromRequest, mergeAgentTools) are available for opt-in conveniences. Migrate to the new names when convenient — both body (client) and data (wire) will be removed in a future major release.
@tanstack/ai-client now POSTs an AG-UI 0.0.52 RunAgentInput request body. The previous fields (messages, data) are emitted alongside the new AG-UI fields so existing servers and clients keep working without code changes.
{
"messages": [...],
"data": {...}
}{
"messages": [...],
"data": {...}
}{
"threadId": "thread-...",
"runId": "run-...",
"state": {},
"messages": [...],
"tools": [...],
"context": [],
"forwardedProps": {...},
"data": {...}
}{
"threadId": "thread-...",
"runId": "run-...",
"state": {},
"messages": [...],
"tools": [...],
"context": [],
"forwardedProps": {...},
"data": {...}
}forwardedProps and data carry the same content. New servers should read forwardedProps; legacy servers reading data keep working unchanged. The data field will be removed in a future major release.
The messages array carries TanStack UIMessage anchors with parts intact, plus AG-UI mirror fields (content, toolCalls) so strict AG-UI servers can parse it. Tool results and thinking parts are additionally emitted as separate {role:'tool',...} and {role:'reasoning',...} fan-out messages alongside the anchors.
This release introduces three compatibility bridges:
| Surface | Before | After (deprecated, still works) | Recommended |
|---|---|---|---|
| Client option (useChat, ChatClient) | body: { ... } | body: { ... } | forwardedProps: { ... } |
| Server wire field | body.data.X | body.data.X (emitted as a mirror of forwardedProps) | body.forwardedProps.X, or params.forwardedProps.X via chatParamsFromRequest |
| Server chat() option | conversationId | conversationId (still accepted) | threadId (or rely on chatParamsFromRequest) |
All three bridges will be removed in the next major release. Until then, you can mix old and new freely — if both body and forwardedProps are passed to useChat, they are merged with forwardedProps winning on key collision.
A jscodeshift codemod is available for the client-side renames. Run it against your codebase to flip every useChat({ body }), new ChatClient({ body }), updateOptions({ body }), Svelte updateBody(...), and chat({ conversationId }) to its canonical name in one pass:
npx jscodeshift \
--parser=tsx \
-t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \
"src/**/*.{ts,tsx}"npx jscodeshift \
--parser=tsx \
-t https://raw.githubusercontent.com/TanStack/ai/main/codemods/ag-ui-compliance/transform.ts \
"src/**/*.{ts,tsx}"Add --dry --print to preview changes first. The codemod is import-source–gated, so files that don't import from @tanstack/ai* packages are left untouched. See codemods/ag-ui-compliance/README.md for the full transform list, conflict-handling rules, and limitations.
Server-side body.data.X rewrites are not automated. Detecting whether a given body.data.foo read belongs to a TanStack AI route handler vs. unrelated code is unreliable in a syntactic codemod. Migrate those by hand using the Tier 2 / Tier 3 recipes below.
conversationId was the pre-AG-UI name for "a stable identifier for this conversation, used to correlate client and server devtools events." AG-UI's threadId is the same concept under the standard name. conversationId is now a deprecated alias of threadId throughout the API — passing either name resolves to the same internal value.
What changed on the wire: the client no longer auto-emits forwardedProps.conversationId. It now sends only the AG-UI top-level threadId field. Anyone who explicitly sets useChat({ forwardedProps: { conversationId } }) (or the legacy body) still has their value passed through unchanged.
What this means for server code:
One real behavior change to verify. If your server reads body.forwardedProps?.conversationId (or the legacy body.data?.conversationId) and threads it into chat({ conversationId }), the value will now be undefined for any client running the upgraded @tanstack/ai-client, because the client no longer auto-emits conversationId. The fall-back to an auto-generated threadId keeps devtools correlation working within a single request, but threadId stability across requests now depends on the client sending its own threadId (which ChatClient does — see the AG-UI top-level threadId field surfaced via params.threadId). To restore the prior cross-request stable identifier, switch the server to read params.threadId and pass it to chat({ threadId: params.threadId }), or rely on the auto-fallback if cross-request stability is not required.
Custom middleware: ChatMiddlewareContext now exposes both ctx.threadId (canonical) and ctx.conversationId (deprecated alias, always equal to ctx.threadId). New middleware should read ctx.threadId; existing middleware reading ctx.conversationId keeps working.
// Before — explicit conversationId plumbing
const params = await chatParamsFromRequest(req)
chat({
messages: params.messages,
conversationId: params.forwardedProps.conversationId, // ← auto-emitted by old client
})
// After — drop the plumbing entirely
const params = await chatParamsFromRequest(req)
chat({ messages: params.messages })
// devtools correlation auto-uses the resolved threadId// Before — explicit conversationId plumbing
const params = await chatParamsFromRequest(req)
chat({
messages: params.messages,
conversationId: params.forwardedProps.conversationId, // ← auto-emitted by old client
})
// After — drop the plumbing entirely
const params = await chatParamsFromRequest(req)
chat({ messages: params.messages })
// devtools correlation auto-uses the resolved threadIdThe upgrade is opt-in: pick the tier that matches the features you use. Most servers fall into Tier 1 and need no code changes.
Keep reading body.messages and pass it through. chat() accepts mixed UIMessage | ModelMessage arrays and handles all AG-UI message-shape quirks internally — fan-out tool dedup, dropping reasoning/activity, collapsing developer → system.
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'
export async function POST(req: Request) {
const body = await req.json()
const provider = body.data?.provider // ← still works (legacy mirror)
// or, equivalently and recommended:
// const provider = body.forwardedProps?.provider
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: body.messages, // AG-UI mixed shape — works directly
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'
export async function POST(req: Request) {
const body = await req.json()
const provider = body.data?.provider // ← still works (legacy mirror)
// or, equivalently and recommended:
// const provider = body.forwardedProps?.provider
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: body.messages, // AG-UI mixed shape — works directly
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}If your existing endpoint reads body.data.X, leave it as-is — the wire emits a data field that mirrors forwardedProps exactly until the next major release. Migrate to body.forwardedProps.X (or Tier 2's params.forwardedProps.X) at your convenience.
Adopt chatParamsFromRequest when you want any of:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}chatParamsFromRequest reads req.json(), validates against AG-UI RunAgentInputSchema, and on failure throws a 400 Response that frameworks like TanStack Start, SolidStart, Remix, and React Router 7 return to the client automatically.
Framework note. Next.js Route Handlers, SvelteKit, Hono, and raw Node do not auto-handle thrown Response objects. In those, either wrap the call with try/catch and return the caught Response, or use chatParamsFromRequestBody(await req.json()) directly with your own error handling.
mergeAgentTools lets the client declare its tools in the request payload (RunAgentInput.tools) and have them registered server-side on a per-request basis. This is purely a convenience over the existing pattern, not a migration requirement.
If you were already registering client-side tools in your server's tools array — even ones without a .server() implementation — that pattern still works exactly as before. The runtime treats tools without execute as client-side and emits ClientToolRequest events; whether the registration came from a static array or mergeAgentTools is irrelevant.
Adopt this tier only if you want the client to drive tool advertisement (e.g., your client surfaces different tools per session and you'd rather not keep the server's static registry in sync). The only delta from Tier 2 is the tools line — wrap serverTools with mergeAgentTools(serverTools, params.tools):
import {
chat,
chatParamsFromRequest,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools
})
return toServerSentEventsResponse(stream)
}import {
chat,
chatParamsFromRequest,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'
export async function POST(req: Request) {
const params = await chatParamsFromRequest(req)
const stream = chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: mergeAgentTools(serverTools, params.tools), // ← merges client-declared tools
})
return toServerSentEventsResponse(stream)
}mergeAgentTools registers client-declared tools as no-execute stubs server-side. The runtime emits a ClientToolRequest event when the model calls one; the client executes via its registered handler and posts the result back.
Skip this section if you're on Tier 1. forwardedProps is only surfaced when you opt into chatParamsFromRequest (or chatParamsFromRequestBody).
forwardedProps is arbitrary client-controlled JSON. Do not spread it directly into chat({...}):
// 🚫 UNSAFE — a client could override `adapter`, `model`, `tools`, system prompts, anything
chat({
adapter: openaiText('gpt-4o'),
...params,
...params.forwardedProps,
})// 🚫 UNSAFE — a client could override `adapter`, `model`, `tools`, system prompts, anything
chat({
adapter: openaiText('gpt-4o'),
...params,
...params.forwardedProps,
})Always destructure the specific fields you intend to forward:
// ✅ SAFE — explicit allowlist
chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: mergeAgentTools(serverTools, params.tools),
temperature:
typeof params.forwardedProps.temperature === 'number'
? params.forwardedProps.temperature
: undefined,
maxTokens:
typeof params.forwardedProps.maxTokens === 'number'
? params.forwardedProps.maxTokens
: undefined,
})// ✅ SAFE — explicit allowlist
chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: mergeAgentTools(serverTools, params.tools),
temperature:
typeof params.forwardedProps.temperature === 'number'
? params.forwardedProps.temperature
: undefined,
maxTokens:
typeof params.forwardedProps.maxTokens === 'number'
? params.forwardedProps.maxTokens
: undefined,
})useChat and the connection adapters (fetchServerSentEvents, fetchHttpStream) handle the new wire format internally. Existing UIMessage state is unchanged. clientTools(...) declarations are now automatically advertised to the server in the request payload.
The body option on useChat / ChatClient is now @deprecated in favor of forwardedProps. Both are accepted, both populate the same wire field. Migrate at your convenience:
// Before — still works, but deprecated
useChat({
connection: fetchServerSentEvents('/api/chat'),
body: { provider: 'openai', model: 'gpt-4o' },
})
// After — recommended
useChat({
connection: fetchServerSentEvents('/api/chat'),
forwardedProps: { provider: 'openai', model: 'gpt-4o' },
})// Before — still works, but deprecated
useChat({
connection: fetchServerSentEvents('/api/chat'),
body: { provider: 'openai', model: 'gpt-4o' },
})
// After — recommended
useChat({
connection: fetchServerSentEvents('/api/chat'),
forwardedProps: { provider: 'openai', model: 'gpt-4o' },
})If both are passed during a partial migration, forwardedProps wins on key collision so stale body values don't shadow new ones.
The Svelte equivalent renames updateBody → updateForwardedProps. The legacy updateBody is retained and marked @deprecated.
If you instantiated a ChatClient directly and want to control the thread identifier, pass threadId via the constructor options:
const client = new ChatClient({
threadId: 'persistent-thread-from-storage',
connection: fetchServerSentEvents('/api/chat'),
tools: [/* clientTools */],
})const client = new ChatClient({
threadId: 'persistent-thread-from-storage',
connection: fetchServerSentEvents('/api/chat'),
tools: [/* clientTools */],
})If you don't pass threadId, one is generated automatically and persists for the lifetime of the ChatClient instance. A fresh runId is generated for every send.
A @tanstack/ai-client request hitting a foreign AG-UI server:
Pure AG-UI RunAgentInput payloads (no TanStack parts field) work end-to-end:
@tanstack/ai now depends on @ag-ui/core@^0.0.52. If your code imports types from @tanstack/ai that re-export AG-UI types, you may need minor type adjustments — see the changeset for specifics.