Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
431ea21
feat(ai): put AG-UI extras in metadata.tanstack
AlemTuzlak Aug 20, 2026
a190344
chore: format and lint fixes
AlemTuzlak Aug 20, 2026
457eede
fix(ai-event-client): accept spec StreamChunk in devtools middleware
AlemTuzlak Aug 20, 2026
7a397e3
fix(openai-base): type adapter-yield tests after rebase onto main
tombeckenham Aug 21, 2026
3e084da
ci: apply automated fixes
autofix-ci[bot] Aug 21, 2026
49fb2cf
fix(ai): keep thinking signatures and TokenUsage after AG-UI spec strip
AlemTuzlak Aug 21, 2026
9547937
Merge origin/main into feat/ag-ui-metadata-compliance
AlemTuzlak Aug 21, 2026
443664c
fix(ai): map AG-UI usage and restore promptTokens
AlemTuzlak Aug 21, 2026
549e5da
docs: document metadata.tanstack for custom AG-UI servers
AlemTuzlak Aug 21, 2026
31b237a
Merge remote-tracking branch 'origin/main' into feat/ag-ui-metadata-c…
AlemTuzlak Aug 21, 2026
19e4d74
fix(ai): restore TOOL_CALL_END.input and spec RUN_ERROR
AlemTuzlak Aug 21, 2026
a670637
docs: keep middleware usage as TokenUsage
AlemTuzlak Aug 21, 2026
dea0c16
fix(ai): drop leftover deltas and round-trip encryptedValue
AlemTuzlak Aug 21, 2026
b39fbf0
revert(ai): drop duplicate TOOL_CALL_START guard
AlemTuzlak Aug 21, 2026
ce3168b
fix(ai): keep leftover content and spec wire extras for CI
AlemTuzlak Aug 21, 2026
c6d1e17
Merge branch 'main' into feat/ag-ui-metadata-compliance
AlemTuzlak Aug 21, 2026
0c5be4d
fix(ai): restore tool-call input/signature/result round-trips after A…
jherr Aug 21, 2026
63d63dc
fix(providers): guard undefined content in chat-completions message m…
jherr Aug 21, 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
16 changes: 16 additions & 0 deletions .changeset/ag-ui-metadata-compliance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@tanstack/ai': minor
'@tanstack/ai-client': minor
'@tanstack/ai-event-client': minor
'@tanstack/ai-persistence': minor
'@tanstack/ai-sandbox': minor
'@tanstack/openai-base': patch
'@tanstack/ai-openrouter': patch
---

Put AG-UI extras under `metadata.tanstack`. SSE/HTTP wire events are spec-only.

`sendMessage({ content, metadata })` stamps user metadata on the user message.
In-process `chat()` still yields `toolName`, `TOOL_CALL_END.input`, and TanStack `TokenUsage`.
Thinking signatures round-trip on `REASONING_ENCRYPTED_VALUE`.
Wire messages use `content` / `toolCalls` / fan-out roles, not `parts`.
25 changes: 22 additions & 3 deletions docs/api/ai-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,27 +112,43 @@ goes away. Users of the framework hooks need no change.

### Methods

#### `sendMessage(content: string)`
#### `sendMessage(content: string | MultimodalContent)`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Sends a user message and gets a response.
Sends a user message and starts the run.

`MultimodalContent` is `{ content, id?, metadata? }`. The string form has no metadata. Pass the object form to stamp `metadata` on the user `UIMessage`. TanStack writes the `tanstack` key. Your keys stay at the top of the bag.

```typescript
import { client } from "./client";

await client.sendMessage("Hello!");

await client.sendMessage({
content: "Show me failed logins",
metadata: { author: { id: "user-42", name: "Dana" } },
});
```

#### `append(message: ModelMessage | UIMessage)`

Appends a message to the conversation.
Appends a message to the conversation. If you pass a `UIMessage`, `append` copies `uiMessage.metadata` onto the stored message.

```typescript
import { client } from "./client";
import type { UIMessage } from "@tanstack/ai-client";

await client.append({
role: "user",
content: "Additional context",
});

const stamped: UIMessage = {
id: "user-1",
role: "user",
parts: [{ type: "text", content: "Show me failed logins" }],
metadata: { author: { id: "user-42", name: "Dana" } },
};
await client.append(stamped);
```

#### `reload()`
Expand Down Expand Up @@ -470,9 +486,12 @@ interface UIMessage {
role: "user" | "assistant";
parts: MessagePart[];
createdAt?: Date;
metadata?: Record<string, any>;
}
```

`metadata` is an optional AG-UI bag (`Record<string, any>`). TanStack writes the `tanstack` key. Your keys stay at the top.

### `MessagePart`

```typescript ignore
Expand Down
107 changes: 50 additions & 57 deletions docs/chat/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ TanStack AI supports streaming responses for real-time chat experiences. Streami

## How Streaming Works

When you use `chat()`, it returns an async iterable stream of chunks:
`chat()` returns an async iterable of spec AG-UI chunks. Branch on `chunk.type`:

```typescript
import { chat } from "@tanstack/ai";
Expand All @@ -28,9 +28,14 @@ const stream = chat({
messages: [{ role: "user", content: "Hello!" }],
});

// Stream contains chunks as they arrive
for await (const chunk of stream) {
console.log(chunk); // Process each chunk
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
console.log(chunk.delta);
}
if (chunk.type === "RUN_FINISHED") {
console.log(chunk.usage);
console.log(chunk.metadata?.tanstack?.finishReason);
}
}
```

Expand Down Expand Up @@ -78,15 +83,17 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction)

### AG-UI Events

- **RUN_STARTED** - Emitted when a run begins
- **TEXT_MESSAGE_START/CONTENT/END** - Text content streaming lifecycle
- **TOOL_CALL_START/ARGS/END** - Tool invocation lifecycle
- **STEP_STARTED/STEP_FINISHED** - Thinking/reasoning steps
- **CUSTOM** - Namespaced extension events (sandbox file changes, Code Mode progress, structured-output completion, and your own `emitCustomEvent` calls) — see the [Custom Events Reference](../protocol/custom-events) for the full typed taxonomy and how to narrow `chunk.value` with a plain `if`
- **RUN_FINISHED** - Run completion with finish reason and usage
- **RUN_ERROR** - Error occurred during the run
Public `StreamChunk` follows AG-UI event types. TanStack extras live under `metadata.tanstack`.

> **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content).
| `chunk.type` | What you read |
| --- | --- |
| `RUN_STARTED` | `threadId`, `runId` |
| `TEXT_MESSAGE_START` / `CONTENT` / `END` | `messageId`, `delta` |
| `TOOL_CALL_START` / `ARGS` / `END` | `toolCallId`, `toolCallName`, args `delta`. Parsed input and output live on `UIMessage` parts |
| `REASONING_*` / `REASONING_ENCRYPTED_VALUE` | Thinking content. See [Thinking & Reasoning](./thinking-content) |
| `STEP_STARTED` / `STEP_FINISHED` | `stepName` only |
| `CUSTOM` | `name` and `value` (sandbox files, Code Mode, `structured-output.*`, `*.session-id`, and your `emitCustomEvent` calls). See [Custom Events](../protocol/custom-events) |
| `RUN_FINISHED` / `RUN_ERROR` | In-process `chat()` still uses TanStack `TokenUsage` (`promptTokens`). The SSE/HTTP wire uses the spec `usage` array (`inputTokens`). `finishReason` is `metadata.tanstack.finishReason`. Custom servers: see [Event metadata](../protocol/metadata) |

### Threads and runs

Expand Down Expand Up @@ -130,12 +137,14 @@ stores the transcript per `threadId`. The media generation hooks take a
`threadId` too, where it names a slot rather than a conversation. See
[Id map](../persistence/id-map).

### Type-Safe Tool Call Events
### Tool input and output

SSE and HTTP `TOOL_CALL_END` does not carry parsed `input`. In-process `chat()` still has `input`. Tool input and output also live on `UIMessage` parts. On the server, feed chunks into `StreamProcessor`. On the client, read `useChat` `messages`.

When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. Prefer the AG-UI field `toolCallName` (or the deprecated `toolName` alias) — both narrow to the union of your tool name literals. The `input` field on `TOOL_CALL_END` is typed as the union of your tool input schemas (typically set on the adapter-emitted END once arguments are complete):
Server:

```typescript
import { chat, toolDefinition } from "@tanstack/ai";
import { chat, StreamProcessor, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";

Expand All @@ -148,34 +157,34 @@ const weatherTool = toolDefinition({
}),
});

const messages = [
{ role: "user" as const, content: "What's the weather in Paris?" },
];

const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
messages: [
{ role: "user", content: "What's the weather in Paris?" },
],
tools: [weatherTool],
});

const processor = new StreamProcessor();
for await (const chunk of stream) {
// `'type' in chunk` is required for control-flow narrowing across the
// StreamChunk union (AG-UI event types from `@ag-ui/core` use Zod
// passthrough, which otherwise hides the discriminant from property access).
if ("type" in chunk && chunk.type === "TOOL_CALL_END") {
chunk.toolCallName; // ✅ typed as "get_weather" (not string)
chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } | undefined
processor.processChunk(chunk);
}
processor.finalizeStream();

for (const message of processor.getMessages()) {
for (const part of message.parts) {
if (part.type === "tool-call") {
console.log(part.name, part.input, part.output);
}
}
}
```

Without typed tools, names default to `string` and `input`/`output` default to `unknown` — the same behavior as before. The type narrowing is automatic when you use `toolDefinition()` with Zod schemas.

When multiple tools are provided, tool call events form a **discriminated union** — checking `toolCallName` (or `toolName`) narrows `input` / `output` to that specific tool's type:
Client: pass your `.client()` tools to `useChat`. Checking `part.name` narrows `part.input` and `part.output`:

```typescript
import { chat, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";

const weatherTool = toolDefinition({
Expand All @@ -185,43 +194,27 @@ const weatherTool = toolDefinition({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]).optional(),
}),
}).client(async (input) => {
return { location: input.location };
});

const searchTool = toolDefinition({
name: "search",
description: "Search the web",
inputSchema: z.object({ query: z.string() }),
});

const messages = [
{ role: "user" as const, content: "Find the weather for Paris" },
];

const stream = chat({
adapter: openaiText("gpt-5.5"),
messages,
tools: [weatherTool, searchTool],
const { messages } = useChat({
connection: fetchServerSentEvents("/api/chat"),
tools: [weatherTool],
});

for await (const chunk of stream) {
if ("type" in chunk && chunk.type === "TOOL_CALL_END") {
if (chunk.toolCallName === "get_weather") {
// ✅ input is narrowed to { location: string; unit?: "celsius" | "fahrenheit" }
console.log(`Weather in ${chunk.input?.location}`);
}
if (chunk.toolCallName === "search") {
// ✅ input is narrowed to { query: string }
console.log(`Searched for: ${chunk.input?.query}`);
for (const message of messages) {
for (const part of message.parts) {
if (part.type === "tool-call" && part.name === "get_weather") {
console.log(part.input?.location);
}
}
}
```

> **Tip:** The typed stream type is exported as `TypedStreamChunk<TTools>`. The default (no type args) matches `ChatStream`: standard chunks plus the known framework `CUSTOM` event union. Free-form `emitCustomEvent` names still flow at runtime; cast to `StreamChunk` if you need to read them.

### Thinking Chunks

Adapters emit reasoning as both the canonical `REASONING_MESSAGE_*` events and the older `STEP_STARTED` / `STEP_FINISHED` events. Rather than parsing those raw events yourself, read the reconciled `ThinkingPart` from `message.parts` — the stream processor merges both event families into a single part for you:
Thinking content comes from `REASONING_*` and `REASONING_ENCRYPTED_VALUE` events. `STEP_STARTED` and `STEP_FINISHED` only carry `stepName`. Read the `ThinkingPart` on `message.parts`:

```typescript
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
Expand All @@ -233,7 +226,7 @@ const { messages } = useChat({
for (const message of messages) {
for (const part of message.parts) {
if (part.type === "thinking") {
console.log("Thinking:", part.content); // Accumulated thinking content
console.log("Thinking:", part.content);
}
}
}
Expand Down
16 changes: 9 additions & 7 deletions docs/chat/thinking-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ keywords:

Some models expose their internal reasoning as "thinking" content -- Claude with extended thinking, OpenAI o-series models with reasoning, and others. TanStack AI captures this as `ThinkingPart` in messages, streamed to your UI in real-time alongside text and tool calls.

Unsigned thinking stays in the UI. Signed thinking is a `ThinkingPart` with a `signature`. Anthropic extended thinking uses this. The next request sends signed thinking back in the same order as the original response, including around provider-executed tools.
Unsigned thinking stays in the UI. Signed thinking is a `ThinkingPart` with a `signature`. Anthropic extended thinking uses this. The next request sends signed thinking back in the same order as the original response, including around provider-executed tools. The next-turn body puts that signature on spec `encryptedValue` on the `role: "reasoning"` fan-out. Stream events use `REASONING_ENCRYPTED_VALUE`.

## How It Works

When a model emits reasoning tokens, the adapter emits AG-UI events for them. Adapters emit `REASONING_MESSAGE_*` events (the preferred, canonical form) **and** the older `STEP_STARTED` / `STEP_FINISHED` events. The stream processor reconciles both into a single `ThinkingPart` on the assistant's `UIMessage`, deduplicating overlapping content. You should rely on the `ThinkingPart` in `message.parts` rather than hand-parsing the raw events:
Read the `ThinkingPart` in `message.parts`. Thinking content comes from `REASONING_*` events and from `REASONING_ENCRYPTED_VALUE`. `STEP_STARTED` and `STEP_FINISHED` only carry `stepName`.

```typescript
interface ThinkingPart {
Expand Down Expand Up @@ -142,12 +142,14 @@ Thinking content streams **before** the final text response. As reasoning tokens

The typical streaming order is:

1. The reasoning block begins (`REASONING_MESSAGE_START`, plus a legacy `STEP_STARTED`)
2. Reasoning tokens stream in (`REASONING_MESSAGE_CONTENT`, plus legacy `STEP_FINISHED` events), accumulating into `ThinkingPart.content`
3. `TEXT_MESSAGE_START` -- the model begins its visible response
4. `TEXT_MESSAGE_CONTENT` (repeated) -- the response text streams in
1. Reasoning starts (`REASONING_START` / `REASONING_MESSAGE_START`). Encrypted blobs use `REASONING_ENCRYPTED_VALUE`.
2. Reasoning tokens stream in (`REASONING_MESSAGE_CONTENT`) and accumulate into `ThinkingPart.content`.
3. `TEXT_MESSAGE_START` starts the visible response.
4. `TEXT_MESSAGE_CONTENT` streams the response text.

Adapters emit both the canonical `REASONING_MESSAGE_*` events and the older `STEP_*` events; the stream processor reconciles them into one `ThinkingPart` so you never have to hand-parse the raw events. If you use `useChat` from `@tanstack/ai-react` (or the Solid/Vue/Svelte equivalents), your `messages` array updates automatically with both thinking and text parts as they arrive.
`STEP_STARTED` and `STEP_FINISHED` only carry `stepName`. They do not carry thinking text.

If you use `useChat` from `@tanstack/ai-react` (or the Solid/Vue/Svelte equivalents), your `messages` array updates with both thinking and text parts as they arrive.

## Next Steps

Expand Down
18 changes: 12 additions & 6 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@
"label": "Streaming",
"to": "chat/streaming",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-30"
"updatedAt": "2026-08-21"
},
{
"label": "Connection Adapters",
Expand All @@ -184,7 +184,8 @@
{
"label": "Thinking & Reasoning",
"to": "chat/thinking-content",
"addedAt": "2026-04-15"
"addedAt": "2026-04-15",
"updatedAt": "2026-08-21"
}
]
},
Expand Down Expand Up @@ -360,11 +361,16 @@
{
"label": "Protocol",
"children": [
{
"label": "Event metadata",
"to": "protocol/metadata",
"addedAt": "2026-08-21"
},
{
"label": "Custom Events Reference",
"to": "protocol/custom-events",
"addedAt": "2026-07-03",
"updatedAt": "2026-08-14"
"updatedAt": "2026-08-21"
}
]
},
Expand All @@ -388,7 +394,7 @@
"label": "Streaming UIs",
"to": "structured-outputs/streaming",
"addedAt": "2026-05-19",
"updatedAt": "2026-08-18"
"updatedAt": "2026-08-20"
},
{
"label": "Multi-Turn Chat",
Expand Down Expand Up @@ -819,7 +825,7 @@
"label": "AG-UI Client Compliance",
"to": "migration/ag-ui-compliance",
"addedAt": "2026-05-16",
"updatedAt": "2026-07-31"
"updatedAt": "2026-08-21"
},
{
"label": "Sampling → modelOptions",
Expand All @@ -841,7 +847,7 @@
"label": "@tanstack/ai-client",
"to": "api/ai-client",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-20"
"updatedAt": "2026-08-21"
},
{
"label": "@tanstack/ai-react",
Expand Down
Loading
Loading