Skip to content
7 changes: 7 additions & 0 deletions .changeset/svelte-callback-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai-svelte': patch
---

fix(ai-svelte): propagate `createChat` callback changes uniformly

`onResponse`, `onChunk`, and `onCustomEvent` were passed as direct references to the underlying `ChatClient`, while `onFinish` and `onError` were wrapped to read from `options.onX?.(...)` at call time. This meant callers who mutated the options object in-place (or invoked `client.updateOptions(...)`) would see their replacement propagate for the latter two but silently miss for the former three. All five user-supplied callbacks now go through the same indirection, matching the React / Preact / Vue / Solid sibling wrappers.
69 changes: 67 additions & 2 deletions docs/chat/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

const stream = chat({
adapter: openaiText("gpt-5.2"),
adapter: openaiText("gpt-4o"),
messages,
});

Expand All @@ -46,7 +46,7 @@ export async function POST(request: Request) {
const { messages } = await request.json();

const stream = chat({
adapter: openaiText("gpt-5.2"),
adapter: openaiText("gpt-4o"),
messages,
});

Expand Down Expand Up @@ -87,6 +87,71 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction)

> **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content).

### Type-Safe Tool Call Events

When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. The `toolName` field narrows to the union of your tool name literals, and the `input` field on `TOOL_CALL_END` events is typed as the union of your tool input schemas:

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

const weatherTool = toolDefinition({
name: "get_weather",
description: "Get weather for a location",
inputSchema: z.object({
location: z.string(),
unit: z.enum(["celsius", "fahrenheit"]).optional(),
}),
});

const stream = chat({
adapter: openaiText("gpt-4o"),
messages,
tools: [weatherTool],
});

for await (const chunk of stream) {
if (chunk.type === "TOOL_CALL_END") {
chunk.toolName; // ✅ typed as "get_weather" (not string)
chunk.input; // ✅ typed as { location: string; unit?: "celsius" | "fahrenheit" } | undefined
}
}
```

Without typed tools, `toolName` defaults to `string` and `input` defaults 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 `toolName` narrows `input` to that specific tool's type:

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

const stream = chat({
adapter: openaiText("gpt-4o"),
messages,
tools: [weatherTool, searchTool],
});

for await (const chunk of stream) {
if (chunk.type === "TOOL_CALL_END") {
if (chunk.toolName === "get_weather") {
// ✅ input is narrowed to { location: string; unit?: "celsius" | "fahrenheit" }
console.log(`Weather in ${chunk.input?.location}`);
}
if (chunk.toolName === "search") {
// ✅ input is narrowed to { query: string }
console.log(`Searched for: ${chunk.input?.query}`);
}
}
}
```

> **Tip:** The typed stream chunk type is exported as `TypedStreamChunk<TTools>` if you need to annotate variables or function parameters. When used without type arguments, `TypedStreamChunk` is equivalent to `StreamChunk`.

### Thinking Chunks

Thinking/reasoning is represented by AG-UI events `STEP_STARTED` and `STEP_FINISHED`. They stream separately from the final response text:
Expand Down
37 changes: 36 additions & 1 deletion docs/reference/type-aliases/StreamChunk.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,42 @@ title: StreamChunk
type StreamChunk = AGUIEvent;
```

Defined in: [packages/typescript/ai/src/types.ts:1152](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1152)
Defined in: [packages/typescript/ai/src/types.ts](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts)

Chunk returned by the SDK during streaming chat completions.
Uses the AG-UI protocol event format.

# Type Alias: TypedStreamChunk

```ts
type TypedStreamChunk<TTools extends ReadonlyArray<Tool<any, any, any>> = ReadonlyArray<Tool<any, any, any>>>
```

Defined in: [types.ts:1065](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/types.ts#L1065)

A variant of `StreamChunk` parameterized by the tools array. When specific tool types are provided (e.g. from `chat({ tools: [myTool] })`):

- `TOOL_CALL_START` and `TOOL_CALL_END` events form a **discriminated union** over tool names.
- Checking `toolName === 'x'` narrows `input` to that specific tool's input type.
- `TOOL_CALL_END` events have `input` typed per-tool via Standard Schema inference.

When tools are untyped or absent, `TypedStreamChunk` degrades to the same type as `StreamChunk`.

This is the type returned by `chat()` when streaming is enabled (the default). You don't typically need to reference it directly unless annotating function parameters or return types.

```ts
import type { TypedStreamChunk } from "@tanstack/ai";
import { toolDefinition } from "@tanstack/ai";

// Given tools created with toolDefinition():
const weatherTool = toolDefinition({ name: "get_weather", description: "...", inputSchema: /* Zod schema */ });
const searchTool = toolDefinition({ name: "search", description: "...", inputSchema: /* Zod schema */ });

// Without type args — equivalent to StreamChunk
type Chunk = TypedStreamChunk;

// With specific tools — tool call events are typed
type TypedChunk = TypedStreamChunk<[typeof weatherTool, typeof searchTool]>;
```

See [Streaming - Type-Safe Tool Call Events](../../chat/streaming) for a practical walkthrough.
2 changes: 2 additions & 0 deletions docs/tools/server-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ const getUserData = getUserDataDef.server(async (args) => {

> **Note:** JSON Schema tools skip runtime validation. Zod schemas are recommended for full type safety and validation.

> **Tip:** When you pass typed tools (server, client, or definition) to `chat()`, the returned stream is fully typed — `toolName` narrows to your tool name literals and `input` narrows per-tool when you check the name. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events).

## Best Practices

1. **Keep tools focused** - Each tool should do one thing well
Expand Down
2 changes: 2 additions & 0 deletions docs/tools/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ const inputSchema: JSONSchema = {

> **Note:** When using JSON Schema, TypeScript will infer `any` for input/output types since JSON Schema cannot provide compile-time type information. Zod schemas are recommended for full type safety.

> **Tip:** Type safety from Zod schemas extends beyond tool execution — when you iterate over the stream returned by `chat()`, tool call events have typed `toolName` and `input` fields too. See [Type-Safe Tool Call Events](../chat/streaming#type-safe-tool-call-events).

## Tool Definition

Tools are defined using `toolDefinition()` from `@tanstack/ai`:
Expand Down
24 changes: 20 additions & 4 deletions packages/typescript/ai-svelte/src/create-chat.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,38 @@ export function createChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
let connectionStatus = $state<ConnectionStatus>('disconnected')
let sessionGenerating = $state(false)

// Create ChatClient instance
// Create ChatClient instance.
//
// Svelte's `createChat` runs once per instance, so `options` is captured by
// reference at creation time. Wrapping each user-supplied callback through
// `options.onX?.(...)` lets callers mutate the options object in place (or
// call `client.updateOptions(...)` imperatively) and have the next invocation
// pick up the new function — without this indirection, those five callbacks
// would be frozen to whatever was passed at `createChat(...)` time, which
// diverges from the React/Preact/Vue/Solid sibling wrappers. This is the
// same uniform treatment applied to `onFinish`/`onError`; the other three
// (`onResponse`, `onChunk`, `onCustomEvent`) used to be direct references.
const client = new ChatClient({
connection: options.connection,
id: clientId,
initialMessages: options.initialMessages,
body: options.body,
onResponse: options.onResponse,
onChunk: options.onChunk,
onResponse: (response) => {
options.onResponse?.(response)
},
onChunk: (chunk) => {
options.onChunk?.(chunk)
},
onFinish: (message) => {
options.onFinish?.(message)
},
onError: (err) => {
options.onError?.(err)
},
tools: options.tools,
onCustomEvent: options.onCustomEvent,
onCustomEvent: (eventType, data, context) => {
options.onCustomEvent?.(eventType, data, context)
},
streamProcessor: options.streamProcessor,
onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
messages = newMessages
Expand Down
56 changes: 41 additions & 15 deletions packages/typescript/ai/src/activities/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
CustomEvent,
InferSchemaType,
ModelMessage,
ProviderTool,
RunFinishedEvent,
SchemaInput,
StreamChunk,
Expand All @@ -45,6 +46,7 @@ import type {
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
TypedStreamChunk,
} from '../../types'
import type {
ChatMiddleware,
Expand All @@ -54,7 +56,6 @@ import type {
} from './middleware/types'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
import type { ProviderTool } from '../../tools/provider-tool'

// ===========================
// Activity Kind
Expand All @@ -74,11 +75,19 @@ export const kind = 'text' as const
* @template TAdapter - The text adapter type (created by a provider function)
* @template TSchema - Optional Standard Schema for structured output
* @template TStream - Whether to stream the output (default: true)
* @template TTools - The tools array type for type-safe tool call events in the stream
*/
export interface TextActivityOptions<
TAdapter extends AnyTextAdapter,
TSchema extends SchemaInput | undefined,
TStream extends boolean,
TTools extends ReadonlyArray<
| (Tool<any, any, any> & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
> = ReadonlyArray<
| (Tool<any, any, any> & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
>,
> {
/** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */
adapter: TAdapter
Expand All @@ -101,10 +110,7 @@ export interface TextActivityOptions<
* `supports.tools` list. Passing an unsupported tool produces a
* compile-time error on the array element.
*/
tools?: Array<
| (Tool & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
>
tools?: TTools
/** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */
temperature?: TextOptions['temperature']
/** Nucleus sampling parameter. The model considers tokens with topP probability mass. */
Expand Down Expand Up @@ -146,7 +152,7 @@ export interface TextActivityOptions<
outputSchema?: TSchema
/**
* Whether to stream the text result.
* When true (default), returns an AsyncIterable<StreamChunk> for streaming output.
* When true (default), returns an AsyncIterable<TypedStreamChunk<TTools>> for streaming output.
* When false, returns a Promise<string> with the collected text content.
*
* Note: If outputSchema is provided, this option is ignored and the result
Expand Down Expand Up @@ -214,9 +220,16 @@ export function createChatOptions<
TAdapter extends AnyTextAdapter,
TSchema extends SchemaInput | undefined = undefined,
TStream extends boolean = true,
TTools extends ReadonlyArray<
| (Tool<any, any, any> & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
> = ReadonlyArray<
| (Tool<any, any, any> & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
>,
>(
options: TextActivityOptions<TAdapter, TSchema, TStream>,
): TextActivityOptions<TAdapter, TSchema, TStream> {
options: TextActivityOptions<TAdapter, TSchema, TStream, TTools>,
): TextActivityOptions<TAdapter, TSchema, TStream, TTools> {
return options
}

Expand All @@ -228,16 +241,22 @@ export function createChatOptions<
* Result type for the text activity.
* - If outputSchema is provided: Promise<InferSchemaType<TSchema>>
* - If stream is false: Promise<string>
* - Otherwise (stream is true, default): AsyncIterable<StreamChunk>
* - Otherwise (stream is true, default): AsyncIterable<TypedStreamChunk<TTools>>
*
* When tools with typed schemas are provided, the stream chunks include
* type-safe `toolName` and `input` fields on tool call events.
*/
export type TextActivityResult<
TSchema extends SchemaInput | undefined,
TStream extends boolean = true,
TTools extends ReadonlyArray<Tool<any, any, any>> = ReadonlyArray<
Tool<any, any, any>
>,
> = TSchema extends SchemaInput
? Promise<InferSchemaType<TSchema>>
: TStream extends false
? Promise<string>
: AsyncIterable<StreamChunk>
: AsyncIterable<TypedStreamChunk<TTools>>

// ===========================
// ChatEngine Implementation
Expand Down Expand Up @@ -1513,9 +1532,16 @@ export function chat<
TAdapter extends AnyTextAdapter,
TSchema extends SchemaInput | undefined = undefined,
TStream extends boolean = true,
TTools extends ReadonlyArray<
| (Tool<any, any, any> & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
> = ReadonlyArray<
| (Tool<any, any, any> & { readonly '~toolKind'?: never })
| ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
>,
>(
options: TextActivityOptions<TAdapter, TSchema, TStream>,
): TextActivityResult<TSchema, TStream> {
options: TextActivityOptions<TAdapter, TSchema, TStream, TTools>,
): TextActivityResult<TSchema, TStream, TTools> {
const { outputSchema, stream } = options

// If outputSchema is provided, run agentic structured output
Expand All @@ -1526,7 +1552,7 @@ export function chat<
SchemaInput,
boolean
>,
) as TextActivityResult<TSchema, TStream>
) as TextActivityResult<TSchema, TStream, TTools>
}

// If stream is explicitly false, run non-streaming text
Expand All @@ -1537,13 +1563,13 @@ export function chat<
undefined,
false
>,
) as TextActivityResult<TSchema, TStream>
) as TextActivityResult<TSchema, TStream, TTools>
}

// Otherwise, run streaming text (default)
return runStreamingText(
options as unknown as TextActivityOptions<AnyTextAdapter, undefined, true>,
) as TextActivityResult<TSchema, TStream>
) as TextActivityResult<TSchema, TStream, TTools>
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export {
export { ToolCallManager } from './activities/chat/tools/tool-calls'

// Provider tool type
export type { ProviderTool } from './tools/provider-tool'
export type { ProviderTool } from './types'

// Agent loop strategies
export {
Expand Down
25 changes: 0 additions & 25 deletions packages/typescript/ai/src/tools/provider-tool.ts

This file was deleted.

Loading
Loading