TanStack AI

The chat/ai/tanstack subpath is the TanStack AI counterpart to the AI SDK helpers in chat/ai. It converts Chat SDK Message[] into the ModelMessage[] shape that chat() from @tanstack/ai expects, and it exposes the same Chat SDK tools, presets, approval flags, and scope guard as createChatTools, shaped for chat({ tools }). The subpath declares its own message and tool types, so it has no runtime dependency on @tanstack/ai.

import { createTanStackTools, toTanStackMessages } from "chat/ai/tanstack";

Install

Add @tanstack/ai and a model adapter. The examples below use @tanstack/ai-vercel-gateway, which routes requests through Vercel AI Gateway so you can switch models by changing the model id. See TanStack AI with AI Gateway for the full setup.

pnpm add @tanstack/ai @tanstack/ai-vercel-gateway zod

Create an AI Gateway API key and expose it as AI_GATEWAY_API_KEY. The adapter reads it automatically, and falls back to VERCEL_OIDC_TOKEN when deployed on Vercel.

createTanStackTools needs zod 4.2 or newer. TanStack AI converts tool input schemas to JSON Schema through the Standard JSON Schema interface, which zod added in 4.2; on an older version createTanStackTools throws with a message that says so. toTanStackMessages has no zod requirement.

For a runnable version, see the TanStack Start example, a Slack bot that uses toTanStackMessages, a TanStack AI server tool, and the AI Gateway adapter.

Converting history

Fetch recent messages the same way you would for toAiMessages, convert them, and hand the array to chat(). The stream chat() returns can be posted straight back to the thread, as described in Streaming.

lib/bot.ts
import { chat } from "@tanstack/ai";
import { vercelGatewayText } from "@tanstack/ai-vercel-gateway";
import { toTanStackMessages } from "chat/ai/tanstack";

bot.onNewMention(async (thread) => {
  const result = await thread.adapter.fetchMessages(thread.id, { limit: 20 });
  const messages = await toTanStackMessages(result.messages);

  const stream = chat({
    adapter: vercelGatewayText("anthropic/claude-opus-5"),
    systemPrompts: ["You are a helpful assistant in a team chat workspace."],
    messages,
  });
  await thread.post(stream);
});

What gets converted

  • Roles: messages authored by the bot (author.isMe === true) become assistant, everything else becomes user. Messages are sorted oldest first by metadata.dateSent.
  • Text: message.text becomes string content. With includeNames: true, user messages are prefixed with [username]: .
  • Images: image attachments with a working fetchData() become { type: "image", source: { type: "data", value, mimeType } } parts, where value is base64 with no data: prefix. A user message with images gets array content, with a leading { type: "text", content } part when there is text.
  • Text files: TanStack AI has no file content part, so text-like attachments (text/*, JSON, XML, YAML, TOML, JavaScript, TypeScript) are inlined into the message text as [File: <name> (<mime>)] followed by the file contents.
  • Links: link metadata is appended to the text inside the same untrusted-content fence that toAiMessages uses, with third-party titles and descriptions normalized and length-limited.
  • Video and audio: skipped, with onUnsupportedAttachment called for each. Other file types (PDF, for example) are skipped silently.
  • Empty messages: a message with no text and nothing the converter can include is dropped.
  • System prompt: ModelMessage has no system role. Pass system text through chat({ systemPrompts: [...] }) instead.

Options

function toTanStackMessages(
  messages: Message[],
  options?: ToTanStackMessagesOptions
): Promise<TanStackMessage[]>
OptionTypeDescription
includeNamesbooleanPrefix user messages with [username]: so the model can tell speakers apart. Defaults to false.
transformMessage(message: TanStackMessage, source: Message) => TanStackMessage | null | Promise<TanStackMessage | null>Runs after default processing for each message. Return the message (modified or as-is) to keep it, or null to drop it.
onUnsupportedAttachment(attachment: Attachment, message: Message) => voidCalled for video and audio attachments. Defaults to console.warn.

Giving the agent tools

createTanStackTools returns an array of plain tool objects (name, description, inputSchema, needsApproval, execute) that chat({ tools }) accepts as-is. TanStack runs the tool loop itself, so server tools execute during the same chat() call and their results feed back into the model before the stream finishes.

lib/bot.ts
import { chat } from "@tanstack/ai";
import { vercelGatewayText } from "@tanstack/ai-vercel-gateway";
import { createTanStackTools, toTanStackMessages } from "chat/ai/tanstack";

bot.onNewMention(async (thread) => {
  const result = await thread.adapter.fetchMessages(thread.id, { limit: 20 });

  const stream = chat({
    adapter: vercelGatewayText("anthropic/claude-opus-5"),
    systemPrompts: ["You operate inside a chat workspace via Chat SDK tools."],
    messages: await toTanStackMessages(result.messages),
    tools: createTanStackTools({
      chat: bot,
      preset: "messenger",
      requireApproval: false,
    }),
  });
  await thread.post(stream);
});

Options

type TanStackChatToolsOptions = {
  chat: Chat;
  preset?: ChatToolPreset | ChatToolPreset[];
  requireApproval?: boolean | Partial<Record<ChatApprovalToolName, boolean>>;
  scope?: ReadScope | false;
  strictScope?: boolean;
  overrides?: Partial<Record<ChatToolName, TanStackToolOverrides>>;
};
OptionDescription
chatThe Chat instance the tools dispatch operations against. Required.
presetPreset (or array of presets) restricting which tools are returned. Omit to get every tool.
requireApprovaltrue (default), false, or per-tool overrides. Applies to every write tool and getUser. See Approval before leaving this on.
scopeConversation the tools are confined to. Defaults to the conversation being handled. When no scope resolves, calls are rejected inside a handler and run with a warning outside one. Pass a Thread, Channel, raw id, or false for workspace-wide access.
strictScopefalse (default). Set true to tighten a thread scope to that thread alone.
overridesPer-tool customization keyed by tool name. Only description, needsApproval (boolean), metadata, and lazy can be set; name, inputSchema, outputSchema, and execute are ignored so tool semantics stay stable.

Presets

The reader, messenger, and moderator presets select the same tools as they do for createChatTools, and they compose the same way (preset: ["reader", "messenger"]). The AI SDK Tools page lists the tools in each preset and describes every available tool.

Approval

Write tools and getUser default to needsApproval: true, matching createChatTools. In TanStack AI that flag means something specific: when the model calls an approval-gated tool, chat() stops the run with a tool-approval interrupt instead of executing it. Resuming is your job. You call chat() again with the message history plus a resume array carrying the decision and the parentRunId of the paused run, as described in TanStack's tool approval docs.

A bot that posts the stream straight to a thread has no UI to collect that decision, so an approval-gated tool call ends the run without the tool running. Unless you implement the resume flow, pass requireApproval: false and gate risky tools yourself, for example with a smaller preset, a tighter scope, or an approval step before chat() is called.

Scope

scope and strictScope behave the same as they do for createChatTools: tools called inside a handler with a conversation are confined to it, and calls that resolve outside it are rejected before the platform is called. Calls with no scope are rejected inside a handler that has no conversation, and run workspace-wide with a warning outside any handler. Pass an explicit scope for background jobs and handlers without a conversation, or scope: false when workspace-wide access is intended. getUser and sendDirectMessage are exempt because they target user ids rather than conversations. Read Limiting what the agent can reach for the full model, including what scope does not check.

Differences from the AI SDK helpers

chat/aichat/ai/tanstack
Tools return typeObject keyed by tool name, spread into toolsArray of tool objects, passed as tools
Tool overridesThe ToolOverrides keys: description, title, needsApproval, metadata, providerOptions, strict, inputExamples, toModelOutput, and the onInput* hooksdescription, needsApproval, metadata, lazy only
Text file attachmentsfile parts with base64 dataInlined into the message text as [File: name (mime)] blocks
Image attachmentsfile parts with data and mediaTypeimage parts with source: { type: "data", value, mimeType }
System promptA system message or the call's system optionchat({ systemPrompts }) only; there is no system role
zod requirementAny version the AI SDK accepts4.2 or newer for createTanStackTools
Approval flowThe AI SDK surfaces an approval request your app confirmschat() pauses with an interrupt you resume via resume and parentRunId

Types

Everything below is exported from chat/ai/tanstack. The subpath also re-exports the tool option types ChatToolName, ChatToolPreset, ChatWriteToolName, ChatApprovalToolName, ApprovalConfig, ReadScope, and ChatBinding from chat/ai unchanged; see the Types page.

type TanStackMessage = TanStackUserMessage | TanStackAssistantMessage;

interface TanStackUserMessage {
  role: "user";
  content: string | TanStackContentPart[];
}

interface TanStackAssistantMessage {
  role: "assistant";
  content: string;
}

type TanStackContentPart = TanStackTextPart | TanStackImagePart;

interface TanStackTextPart {
  type: "text";
  content: string;
}

interface TanStackImagePart {
  type: "image";
  source: { type: "data"; value: string; mimeType: string };
}

interface TanStackTool<TInput = unknown, TOutput = unknown> {
  name: string;
  description: string;
  inputSchema: ZodType<TInput>;
  execute(args: TInput, context?: unknown): Promise<TOutput>;
  needsApproval?: boolean;
  metadata?: Record<string, unknown>;
  lazy?: boolean;
}

type TanStackToolOverrides = Partial<
  Pick<TanStackTool, "description" | "lazy" | "metadata" | "needsApproval">
>;

TanStackMessage[] is structurally assignable to TanStack AI's ModelMessage[], and TanStackTool[] to the tools option of chat(), without importing anything from @tanstack/ai.

Read more