Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8b1c396
chore(ai): bump @ag-ui/core to ^0.0.52
AlemTuzlak Apr 27, 2026
c2f7405
feat(ai): accept parentRunId in TextOptions for AG-UI run correlation
AlemTuzlak Apr 27, 2026
6fde945
feat(ai): dedup AG-UI fan-out + handle reasoning/activity/developer r…
AlemTuzlak Apr 27, 2026
dc8c172
feat(ai): add chatParamsFromRequestBody for AG-UI server endpoints
AlemTuzlak Apr 27, 2026
d9f7408
refactor(ai): use AGUIContext type for context field in chatParamsFro…
AlemTuzlak Apr 27, 2026
83c168d
feat(ai): add mergeAgentTools helper (server wins on collision)
AlemTuzlak Apr 27, 2026
2a58cdb
feat(ai): export chatParamsFromRequestBody and mergeAgentTools
AlemTuzlak Apr 27, 2026
7f865d0
feat(ai): add uiMessagesToWire serializer for AG-UI request body
AlemTuzlak Apr 27, 2026
622bb80
feat(ai-client): connection adapters POST AG-UI RunAgentInput payload
AlemTuzlak Apr 27, 2026
6680b40
feat(ai-client): generate threadId per session, runId per send, adver…
AlemTuzlak Apr 27, 2026
a2febe2
feat(ai): accept UIMessage/ModelMessage in chat() messages; migrate t…
AlemTuzlak Apr 27, 2026
16ae255
chore(examples): migrate chat endpoints to AG-UI RunAgentInput
AlemTuzlak Apr 27, 2026
7631d00
test(e2e): migrate test endpoints to AG-UI RunAgentInput + add compli…
AlemTuzlak Apr 27, 2026
4949038
test(e2e): add foreign AG-UI client and legacy wire shape rejection s…
AlemTuzlak Apr 27, 2026
21a0f90
docs: add AG-UI client compliance migration guide
AlemTuzlak Apr 27, 2026
e8e865a
docs(ai): document client-to-server AG-UI compliance and wire format
AlemTuzlak Apr 27, 2026
2c3cf39
changeset: AG-UI client-to-server compliance (breaking)
AlemTuzlak Apr 27, 2026
154d97c
fix(ai): migrate vue example, validate parts shape, refresh skill ten…
AlemTuzlak Apr 27, 2026
8b1cdb6
ci: apply automated fixes
autofix-ci[bot] Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/ag-ui-client-compliance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@tanstack/ai': minor
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-vue': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-react-ui': minor
---

**Breaking:** AG-UI client-to-server compliance.

`@tanstack/ai-client` now POSTs an AG-UI `RunAgentInput` request body and `@tanstack/ai` server endpoints must use the new `chatParamsFromRequestBody` + `mergeAgentTools` helpers. Upgrade both packages together.

Highlights:

- **Wire format**: `{threadId, runId, state, messages, tools, context, forwardedProps}` (per AG-UI 0.0.52 `RunAgentInputSchema`) instead of `{messages, data}`.
- **New server helpers** exported from `@tanstack/ai`: `chatParamsFromRequestBody`, `mergeAgentTools`.
- **`chat()` accepts `threadId`, `runId`, `parentRunId`** as optional fields for AG-UI run correlation.
- **`ChatClient` accepts `threadId`** option; auto-generates and persists per session if omitted; fresh `runId` per send.
- **Client tools auto-advertised** to the server via `RunAgentInput.tools`.
- **Foreign AG-UI clients** can hit a TanStack server: `developer` collapses to `system`, `reasoning`/`activity` drop.

See `docs/migration/ag-ui-compliance.md` for full migration steps.
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@
{
"label": "From Vercel AI SDK",
"to": "migration/migration-from-vercel-ai"
},
{
"label": "AG-UI Client Compliance",
"to": "migration/ag-ui-compliance"
}
]
},
Expand Down
180 changes: 180 additions & 0 deletions docs/migration/ag-ui-compliance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
---
title: Migrating to AG-UI Client-to-Server Compliance
---

# Migrating to AG-UI Client-to-Server Compliance

> **TL;DR:** Upgrade `@tanstack/ai` and `@tanstack/ai-client` together. The HTTP wire format changed to AG-UI `RunAgentInput`. Server endpoints must use `chatParamsFromRequestBody` and `mergeAgentTools`. Clients have nothing to do — `useChat` and the connection adapters handle the new format internally.

## What changed

`@tanstack/ai-client` now POSTs an AG-UI 0.0.52 `RunAgentInput` request body. The previous shape (`{ messages, data, ...optionsBody }`) is no longer accepted by `@tanstack/ai`'s server helpers.

### Old wire shape

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

### New wire shape

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

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.

## Server endpoint upgrade

### Before

```ts
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'

export async function POST(req: Request) {
const { messages } = await req.json()
const stream = chat({
adapter: openaiText('gpt-4o'),
messages,
tools: serverTools,
})
return toServerSentEventsResponse(stream)
}
```

### After

```ts
import {
chat,
chatParamsFromRequestBody,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai/adapters'

export async function POST(req: Request) {
let params
try {
params = await chatParamsFromRequestBody(await req.json())
} catch (error) {
return new Response(
error instanceof Error ? error.message : 'Bad request',
{ status: 400 },
)
}

const stream = chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: mergeAgentTools(serverTools, params.tools),
threadId: params.threadId,
runId: params.runId,

// Explicitly allowlist forwardedProps — see security note below
temperature:
typeof params.forwardedProps.temperature === 'number'
? params.forwardedProps.temperature
: undefined,
maxTokens:
typeof params.forwardedProps.maxTokens === 'number'
? params.forwardedProps.maxTokens
: undefined,
})

return toServerSentEventsResponse(stream)
}
```

`chatParamsFromRequestBody` validates the body against `RunAgentInputSchema` from `@ag-ui/core` and throws an `AGUIError` on a malformed shape. Surface this as HTTP 400.

## `forwardedProps` security

`forwardedProps` is arbitrary client-controlled JSON. **Do not** spread it directly into `chat({...})`:

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

```ts
// ✅ SAFE — explicit allowlist
chat({
adapter: openaiText('gpt-4o'),
messages: params.messages,
tools: mergeAgentTools(serverTools, params.tools),
threadId: params.threadId,
runId: params.runId,
temperature: typeof params.forwardedProps.temperature === 'number'
? params.forwardedProps.temperature
: undefined,
})
```

## Client-side: nothing to do

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

If you instantiated a `ChatClient` directly and want to control the thread identifier, pass `threadId` via the constructor options:

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

## Tool-merge semantics

- **Server tools win on name collision.** A tool registered server-side via `toolDefinition().server(...)` always executes server-side.
- **Client-only tools become no-execute stubs** in `chat()`. The runtime emits a `ClientToolRequest` event back to the client; the client's registered handler (via `clientTools(...)`) executes locally and posts the result.
- **Dual-handler (both have it):** server executes, then `chat-client.ts`'s `onToolCall` fires the client's handler as a UI side-effect when the streamed tool result event arrives. The server's result is authoritative for the conversation.

## Talking to a foreign AG-UI server

A `@tanstack/ai-client` request hitting a foreign AG-UI server:

- ✅ Single-turn user messages work — content is mirrored to AG-UI's `content` field.
- ✅ Server-emitted events stream and render correctly.
- ✅ Multi-turn history that includes tool results from prior turns: the foreign server reads them via the AG-UI fan-out duplicates we send (separate `{role:'tool',...}` messages).
- ⚠️ Client-only tools are sent in the AG-UI `tools` field; whether the foreign server actually invokes them depends on its tool-calling logic.

## Talking to a TanStack server from a foreign AG-UI client

Pure AG-UI `RunAgentInput` payloads (no TanStack `parts` field) work end-to-end:

- Tool messages pass through as `ModelMessage` entries with `role: 'tool'`.
- `reasoning` messages are dropped (no LLM-replay equivalent today).
- `activity` messages are dropped (no TanStack equivalent).
- `developer` messages are collapsed to `system` role.

## `@ag-ui/core` bump

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

## Out of scope (existing behavior preserved)

- **Reasoning replay to LLM providers.** TanStack still drops `ThinkingPart` at the `UIMessage`→`ModelMessage` boundary (pre-existing behavior). Providers like Anthropic that require thinking blocks to be replayed for extended thinking continuation remain a separate concern, tracked outside this migration.
- **AG-UI `state` and `context` fields.** Surfaced on `chatParamsFromRequestBody`'s return value but not yet wired into `chat()`. They're available for your endpoint to inspect/forward, but the runtime ignores them.
- **PHP and Python server packages.** No `chatParamsFromRequestBody` parity yet. Their examples temporarily lag on the old shape until the matching helpers ship.
67 changes: 44 additions & 23 deletions examples/ts-react-chat/src/routes/api.tanchat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import {
chat,
chatParamsFromRequestBody,
createChatOptions,
maxIterations,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
Expand All @@ -12,7 +14,7 @@ import { geminiText } from '@tanstack/ai-gemini'
import { openRouterText } from '@tanstack/ai-openrouter'
import { grokText } from '@tanstack/ai-grok'
import { groqText } from '@tanstack/ai-groq'
import type { AnyTextAdapter, ChatMiddleware } from '@tanstack/ai'
import type { AnyTextAdapter, ChatMiddleware, Tool } from '@tanstack/ai'
import {
addToCartToolDef,
addToWishListToolDef,
Expand Down Expand Up @@ -75,6 +77,22 @@ const addToCartToolServer = addToCartToolDef.server((args, context) => {
}
})

const serverToolsList = [
getGuitars, // Server tool
recommendGuitarToolDef, // No server execute - client will handle
addToCartToolServer,
addToWishListToolDef,
getPersonalGuitarPreferenceToolDef,
// Lazy tools - discovered on demand
compareGuitars,
calculateFinancing,
searchGuitars,
]

const serverTools: Record<string, Tool> = Object.fromEntries(
serverToolsList.map((t) => [t.name, t]),
)

const loggingMiddleware: ChatMiddleware = {
name: 'logging',
onConfig(ctx, config) {
Expand Down Expand Up @@ -122,13 +140,26 @@ export const Route = createFileRoute('/api/tanchat')({

const abortController = new AbortController()

const body = await request.json()
const { messages, data } = body
let params
try {
params = await chatParamsFromRequestBody(await request.json())
} catch (error) {
return new Response(
error instanceof Error ? error.message : 'Bad request',
{ status: 400 },
)
}

// Extract provider and model from data
const provider: Provider = data?.provider || 'openai'
const model: string = data?.model || 'gpt-4o'
const conversationId: string | undefined = data?.conversationId
// Extract provider and model from forwardedProps (sent by the client)
const provider: Provider =
typeof params.forwardedProps.provider === 'string' &&
(params.forwardedProps.provider as Provider)
? (params.forwardedProps.provider as Provider)
: 'openai'
Comment on lines +154 to +158
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Provider check accepts any non-empty string and can crash the handler on bad input.

typeof params.forwardedProps.provider === 'string' &&
(params.forwardedProps.provider as Provider)
  ? (params.forwardedProps.provider as Provider)
  : 'openai'

The second condition (params.forwardedProps.provider as Provider) only tests truthiness — every non-empty string passes. If a client sends forwardedProps.provider: 'unknown', provider becomes 'unknown', then adapterConfig[provider]() at line 223 throws because the entry is undefined. The error is caught and returned as a 500, but it should be a 400 (or just fall back to 'openai').

The Svelte example correctly uses params.forwardedProps.provider in adapterConfig for this.

🛡️ Suggested fix
-        // Extract provider and model from forwardedProps (sent by the client)
-        const provider: Provider =
-          typeof params.forwardedProps.provider === 'string' &&
-          (params.forwardedProps.provider as Provider)
-            ? (params.forwardedProps.provider as Provider)
-            : 'openai'
+        // Extract provider and model from forwardedProps (sent by the client)
+        const candidateProvider =
+          typeof params.forwardedProps.provider === 'string'
+            ? params.forwardedProps.provider
+            : ''
+        // Note: adapterConfig is defined below; defer until after it's in scope
+        // or move the validation after the adapterConfig declaration.

Or, alternatively, hoist the adapterConfig keys into a Set and validate against it before defaulting to 'openai'.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/ts-react-chat/src/routes/api.tanchat.ts` around lines 154 - 158, The
provider selection currently treats any non-empty string as valid, so set
provider must be validated against the known adapters before using it; change
the logic that computes provider (the expression using
params.forwardedProps.provider and Provider) to check membership in
adapterConfig (or a Set of adapterConfig keys) and only accept it if it exists,
otherwise default to 'openai'. Specifically, validate
params.forwardedProps.provider against adapterConfig keys prior to calling
adapterConfig[provider]() to prevent undefined lookups and return a 400 or fall
back to 'openai' when the provided key is not present.

const model: string =
typeof params.forwardedProps.model === 'string'
? params.forwardedProps.model
: 'gpt-4o'

// Pre-define typed adapter configurations with full type inference
// Model is passed to the adapter factory function for type-safe autocomplete
Expand Down Expand Up @@ -191,28 +222,18 @@ export const Route = createFileRoute('/api/tanchat')({
// Get typed adapter options using createChatOptions pattern
const options = adapterConfig[provider]()

// Note: We cast to AsyncIterable<StreamChunk> because all chat adapters
// return streams, but TypeScript sees a union of all possible return types
const mergedTools = mergeAgentTools(serverTools, params.tools)

const stream = chat({
...options,

tools: [
getGuitars, // Server tool
recommendGuitarToolDef, // No server execute - client will handle
addToCartToolServer,
addToWishListToolDef,
getPersonalGuitarPreferenceToolDef,
// Lazy tools - discovered on demand
compareGuitars,
calculateFinancing,
searchGuitars,
],
tools: Object.values(mergedTools),
middleware: [loggingMiddleware],
systemPrompts: [SYSTEM_PROMPT],
agentLoopStrategy: maxIterations(20),
messages,
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
abortController,
conversationId,
})
return toServerSentEventsResponse(stream, { abortController })
} catch (error: any) {
Expand Down
31 changes: 27 additions & 4 deletions examples/ts-solid-chat/src/routes/api.chat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { createFileRoute } from '@tanstack/solid-router'
import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai'
import {
chat,
chatParamsFromRequestBody,
maxIterations,
mergeAgentTools,
toServerSentEventsResponse,
} from '@tanstack/ai'
import type { Tool } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { serverTools } from '@/lib/guitar-tools'

const serverToolsRecord: Record<string, Tool> = Object.fromEntries(
serverTools.map((t) => [t.name, t]),
)

const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store.

CRITICAL INSTRUCTIONS - YOU MUST FOLLOW THIS EXACT WORKFLOW:
Expand Down Expand Up @@ -53,15 +64,27 @@ export const Route = createFileRoute('/api/chat')({

const abortController = new AbortController()

const { messages } = await request.json()
let params
try {
params = await chatParamsFromRequestBody(await request.json())
} catch (error) {
return new Response(
error instanceof Error ? error.message : 'Bad request',
{ status: 400 },
)
}

try {
const mergedTools = mergeAgentTools(serverToolsRecord, params.tools)
// Use the stream abort signal for proper cancellation handling
const stream = chat({
adapter: anthropicText('claude-sonnet-4-5'),
tools: serverTools,
tools: Object.values(mergedTools),
systemPrompts: [SYSTEM_PROMPT],
agentLoopStrategy: maxIterations(20),
messages,
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
modelOptions: {
thinking: {
type: 'enabled',
Expand Down
Loading
Loading