Skip to content
Migration

Migrating to AG-UI Client-to-Server Compliance

@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.

Upgrade

  1. Upgrade @tanstack/ai and @tanstack/ai-client (and your framework package) at the same time.
  2. Run the codemod. It renames body and conversationId for you.
  3. Change your server to read forwardedProps. Pick a tier.
  4. If you read client values from forwardedProps, allowlist each field.
  5. Do the done check.

Renamed options

Surface0.x1.0
Client option (useChat, ChatClient)body: { ... }forwardedProps: { ... }
Server wire fieldbody.data.Xbody.forwardedProps.X, or params.forwardedProps.X from chatParamsFromRequest
Server chat() optionconversationIdthreadId (or use params.threadId from chatParamsFromRequest)

0.x accepted both names. 1.0 accepts only the new names.

diff
  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.

Run the codemod

shell
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.

Update your server endpoint

Pick the tier that matches the features you use. Most servers fit Tier 1.

Tier 1: keep body.messages

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.

ts
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:

  • A clean 400 response for a body that is not a valid AG-UI RunAgentInput.
  • forwardedProps for options that the client sends (provider, model, and more).
  • The AG-UI ids threadId, runId, and parentRunId. chat() needs threadId, so pass params.threadId. chat() makes a runId when you do not pass one.
ts
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.

Tier 3: let the client advertise its tools

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:

ts
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.

  • A client cannot replace a server tool. The server tool wins on a name collision.
  • A client-declared tool has no execute. It runs only on that same client.
  • A client can still add tools that the model sees. Through tool names and descriptions, it can put any text in the context of the model. This is a prompt-injection risk.

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.

Keep forwardedProps safe

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({...}):

ts
// 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:

ts
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)
}

Done

Your server reads forwardedProps (or params.forwardedProps), no code reads body.data, and a chat message streams a reply.

Advanced

The wire shape

Old wire shape:

json
{
  "messages": [...],
  "data": {...}
}

New wire shape:

json
{
  "threadId": "thread-...",
  "runId": "run-...",
  "state": {},
  "messages": [...],
  "tools": [...],
  "context": [],
  "forwardedProps": {...}
}

The messages array on the wire is spec AG-UI:

  • Messages use content, toolCalls, and metadata. They have no parts field.
  • Tool results go out as extra { role: "tool" } messages.
  • Thinking goes out as extra { role: "reasoning" } messages.

The tools that you pass to useChat({ tools }) go to the server in the request body.

Event and message extras

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.

ts
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);
  }
}
  • Wire events. The spec fields stay at the top. The TanStack fields go in metadata.tanstack. For custom servers, see Event metadata.
  • Wire messages. They use content, toolCalls, and extra role: "tool" and role: "reasoning" messages. They have no parts field.
  • chat() chunks. In-process chat() still yields TOOL_CALL_END.input and the TanStack TokenUsage (promptTokens). The SSE and HTTP wire changes usage to the spec array (inputTokens).
  • Usage in middleware. onUsage still gets the TanStack TokenUsage (promptTokens, completionTokens).

For the for await loop, see Stream Events. For onUsage, see Middleware.

conversationId and threadId

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.

  • If your server does not use conversationId, pass chat({ threadId: params.threadId }). chat() needs a threadId and does not make one.
  • If your server passes chat({ conversationId }), pass chat({ threadId: params.threadId }).
  • If your middleware reads ctx.conversationId, read ctx.threadId.

The codemod renames the conversationId key, but it does not change the value. Check the value after the codemod runs:

ts
// 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 })

Set the thread id on the client

Pass threadId to the ChatClient constructor. It is required:

ts
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.

Map forwarded values into runtime context

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:

ts
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,
  },
})

Tool-merge rules

  • The server tool wins on a name collision. A tool registered on the server with toolDefinition().server(...) always runs on the server.
  • A client-only tool becomes a stub with no execute in chat(), when you register it with mergeAgentTools. When the model calls it, the run ends with a client-tool interrupt. The client runs its registered handler (the .client(...) tool in the tools array of the hook), then sends the result back.
  • Both sides have the tool. The server runs it. When the tool result event arrives, the onToolCall of chat-client.ts also runs the client handler, as a UI side effect. The result from the server is the one that the conversation keeps.

Talk to a foreign AG-UI server

When a @tanstack/ai-client request goes to an AG-UI server that is not TanStack AI:

  • Single-turn user messages work. The client copies the content to the AG-UI content field.
  • Events from the server stream and render.
  • Multi-turn history with tool results works. The foreign server reads the tool results from the extra { role: 'tool' } messages that the client sends.
  • Client-only tools go in the AG-UI tools field. Whether the foreign server calls them depends on its own tool logic.

Talk to a TanStack server from a foreign AG-UI client

A spec AG-UI RunAgentInput body (with no TanStack parts field) works end to end:

  • Tool messages pass through as ModelMessage entries with role: 'tool'.
  • reasoning messages attach to the next assistant message as thinking. The spec encryptedValue becomes ThinkingPart.signature.
  • activity messages are dropped. TanStack AI has no equivalent.
  • developer messages become system messages.

@ag-ui/core and zod

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:

  • Their signatures.
  • The errors they throw: an AGUIError from chatParamsFromRequestBody, and a 400 Response from chatParamsFromRequest.
  • The parts passthrough on messages.

The error message names the field that is wrong, for example:

plaintext
Request body is not a valid AG-UI RunAgentInput. ... Validation errors: messages[1].content must be a string

You can still define tools with zod. If your project used zod without a dependency entry (from the transitive copy), add zod:

shell
npm i zod

If you define tools with a different Standard Schema library (ArkType, Valibot), you can remove zod.

What this change does not touch

  • Reasoning replay to LLM providers. Signed ThinkingParts become ModelMessage.thinking, and they replay in the original order, also around provider-executed tools. Unsigned thinking does not go back to the provider.
  • The AG-UI state and context fields. chatParamsFromRequestBody returns them as state and aguiContext. You can read them or forward them. The typed runtime context is the separate chat({ context }) option. If tools or middleware must read AG-UI values, check them and map them into chat({ context }) yourself.