- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Multimodal Content
A message part can carry an image, audio clip, video, or PDF. The model then answers about that file.
Make the content of the message an array of parts. One part holds the text, and the other parts hold the image, audio, video, or document.
Quick setup#
This setup lets a user pick an image and ask about it. It takes about 5 minutes.
1. Use a model that reads images#
Your chat route does not change. Pick a model that takes image input:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
});
return toServerSentEventsResponse(stream);
}2. Send the image from the browser#
Read the file as base64. Then send a text part and an image part together:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
function readAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== "string") {
reject(new Error("Could not read the file"));
return;
}
// Remove the "data:image/png;base64," prefix.
resolve(result.slice(result.indexOf(",") + 1));
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function AskAboutImage() {
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
async function send(file: File) {
const base64 = await readAsBase64(file);
await sendMessage({
content: [
{ type: "text", content: "What is in this image?" },
{
type: "image",
source: { type: "data", value: base64, mimeType: file.type },
},
],
});
}
return (
<input
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void send(file);
}}
/>
);
}Pick an image, and the model describes it in the chat.
How it works#
- content is a string or an array of parts. A string is the same as one text part.
- Each part has a type: text, image, audio, video, or document.
- Each media part has a source that says where the bytes are. See Source types.
- The adapter turns each part into the format of its provider.
- The types of chat() allow only the input types that the selected model supports.
Advanced#
Content parts#
The part types come from @tanstack/ai:
import type { ImagePart, TextPart } from "@tanstack/ai";
const textPart: TextPart = {
type: "text",
content: "What do you see in this image?",
};
// Image from base64 data. `mimeType` is required for data sources.
const imagePart: ImagePart = {
type: "image",
source: {
type: "data",
value: "base64EncodedImageData...",
mimeType: "image/jpeg",
},
metadata: {
detail: "high", // OpenAI detail level
},
};
// Image from a URL. `mimeType` is optional for URL sources.
const imageUrlPart: ImagePart = {
type: "image",
source: {
type: "url",
value: "https://example.com/image.jpg",
mimeType: "image/jpeg",
},
};All the part types:
import type {
AudioPart,
ContentPart,
DocumentPart,
ImagePart,
TextPart,
VideoPart,
} from "@tanstack/ai";
// Metadata types for each provider
import type { OpenAIImageMetadata } from "@tanstack/ai-openai";
import type { AnthropicImageMetadata } from "@tanstack/ai-anthropic";
import type { GeminiImageMetadata } from "@tanstack/ai-gemini";On the server, you can build the same message in a chat() call:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [
{
role: "user",
content: [
{ type: "text", content: "What is in this image?" },
{
type: "image",
source: { type: "url", value: "https://example.com/photo.jpg" },
},
],
},
],
threadId: "thread-1",
});Source types#
Each media part has one of three sources.
Data (base64)#
Use type: 'data' for inline base64 bytes. The mimeType field is required, so the provider gets the correct content type:
import type { AudioPart, ImagePart } from "@tanstack/ai";
const imagePart: ImagePart = {
type: "image",
source: {
type: "data",
value: "iVBORw0KGgoAAAANSUhEUgAAAAUA...",
mimeType: "image/png",
},
};
const audioPart: AudioPart = {
type: "audio",
source: {
type: "data",
value: "base64AudioData...",
mimeType: "audio/mp3",
},
};Data is good for small files, or when the file is not on a public URL.
URL#
Use type: 'url' for a file at a URL. The mimeType field is optional, because the provider can often find the type from the URL or the response headers:
import type { ImagePart } from "@tanstack/ai";
const imagePart: ImagePart = {
type: "image",
source: {
type: "url",
value: "https://example.com/image.jpg",
mimeType: "image/jpeg",
},
};A URL is good for large files that are already online. Not all providers accept a URL for all input types. Read the provider docs.
File handle (Files API)#
Use type: 'file' for a file that you uploaded one time with the Files API of a provider. The provider keeps the bytes, and each request sends a small reference. The source holds the opaque handle and the provider that issued it: { type: 'file', value: 'file-...', provider: 'openai' }.
import { chat, fileSourceFromHandle, uploadFile } from "@tanstack/ai";
import { openaiFiles, openaiText } from "@tanstack/ai-openai";
import { pdfBase64 } from "./pdf-data";
// Upload one time.
const handle = await uploadFile({
adapter: openaiFiles(),
input: { data: pdfBase64, mimeType: "application/pdf" },
});
// Then use the handle in as many requests as you want.
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [
{
role: "user",
content: [
{ type: "text", content: "Summarize this document" },
{ type: "document", source: fileSourceFromHandle(handle) },
],
},
],
threadId: "thread-1",
});What fileSourceFromHandle(handle) does:
- It builds the { type: 'file', value, provider } source.
- It picks the handle URL for Gemini, fal, and Grok, and the opaque id for OpenAI and Anthropic.
- Each adapter maps value to the native field of the provider (file_id, fileData.fileUri, or a URL).
These cases throw an error:
- A handle that you send to a different provider.
- A handle that you send to an adapter with no Files API support. The adapter rejects it before it builds the request.
- A handle that you send to an endpoint that needs the bytes, such as image edits or Veo.
See Files API to upload, get, and delete handles.
Images in tool results#
A tool can return an array of ContentPart. Use it to give the model a screenshot or a chart:
import { toolDefinition } from "@tanstack/ai";
import type { ContentPart } from "@tanstack/ai";
import { z } from "zod";
const getScreenshot = toolDefinition({
name: "get_screenshot",
description: "Get a screenshot of the page",
inputSchema: z.object({}),
}).server(async (): Promise<Array<ContentPart>> => [
{ type: "text", content: "Page screenshot" },
{
type: "image",
source: { type: "data", value: "iVBORw0KGgo...", mimeType: "image/png" },
},
]);The adapter sends the image in the form that its API accepts:
- Responses APIs (openaiText, openRouterResponsesText): the image stays in the tool output.
- Chat Completions APIs (openRouterText, openaiChatCompletions, and the other Chat Completions adapters built on @tanstack/openai-base): a tool message holds text only. The text goes in the tool message. The images go in one user message after the tool results. If a result has only images, its tool text is (see attached image).
Some more rules:
- If these adapters know that the model has no image input, they do not send the images.
- An adapter that does not know the input types of the model sends the images. A result with only images then has the text (image omitted: this model has no image input).
- The Chat Completions adapters and openRouterResponsesText send only the text and the images of a result that has images. They do not send audio, video, or documents from it.
- When an adapter sends an image from a tool result, a file source is an error, except on openaiText. Use a data or url source.
Provider support#
| Provider | Adapter | Input besides text |
|---|---|---|
| OpenAI | openaiText | Image on gpt-6.1-sol and most new models. Image and PDF document on gpt-5.5. Audio on gpt-audio. |
| Anthropic | anthropicText | Image and PDF document on all Claude models, for example claude-sonnet-5-5. |
| Gemini | geminiText | Image, audio, video, and document, for example on gemini-3.8-flash. |
| Ollama | ollamaText | Image on vision models, such as llama3.2-vision. Support changes by model. |
For the full list of each model, read supports.input in the model-meta.ts file of the adapter package.
ollamaText(model) reads the host from the OLLAMA_HOST environment variable. The default is http://localhost:11434.
OpenAI image detail and PDF filename#
OpenAI reads two metadata fields:
- detail on an image: 'auto', 'low', or 'high'.
- filename on a document. The API needs a filename with inline data. The default is document.pdf.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { imageBase64, pdfBase64 } from "./data";
const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [
{
role: "user",
content: [
{ type: "text", content: "Does the chart match the report?" },
{
type: "image",
source: { type: "data", value: imageBase64, mimeType: "image/jpeg" },
metadata: { detail: "high" },
},
{
type: "document",
source: { type: "data", value: pdfBase64, mimeType: "application/pdf" },
metadata: { filename: "report.pdf" },
},
],
},
],
threadId: "thread-1",
});Send any file type from the browser#
This input accepts images, audio, video, and PDFs. It picks the part type from the MIME type of the file. It uses readAsBase64 from the quick setup:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { readAsBase64 } from "./read-as-base64";
export function ChatWithFileUpload() {
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
async function handleFileUpload(file: File) {
const base64 = await readAsBase64(file);
const type = file.type.startsWith("image/")
? "image"
: file.type.startsWith("audio/")
? "audio"
: file.type.startsWith("video/")
? "video"
: "document";
await sendMessage({
content: [
{ type: "text", content: `Please analyze this ${type}` },
{
type,
source: { type: "data", value: base64, mimeType: file.type },
},
],
});
}
return (
<input
type="file"
accept="image/*,audio/*,video/*,.pdf"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleFileUpload(file);
}}
/>
);
}The model on the server must accept each type that you allow here.
Message ID and per-message props#
sendMessage takes a string or a MultimodalContent object. These examples use ChatClient from @tanstack/ai-client. The sendMessage of useChat takes the same first argument. For per-message props, it takes { body } as the second argument.
Give the message your own ID:
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
await client.sendMessage({
content: "Hello!",
id: "custom-message-id-123",
});The second argument adds forwardedProps for that one request. They are shallow-merged with the base forwardedProps of the client, and the values of the message win:
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";
const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
forwardedProps: { model: "gpt-6.1-sol" },
});
await client.sendMessage("Analyze this complex problem", {
temperature: 0.2,
});Validate messages from a request#
request.json() returns any. TanStack AI does not ship a runtime message validator. Define a schema with a Standard Schema library (Zod, Valibot, ArkType, and more), and parse the body before you give it to chat():
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const ContentPartSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), content: z.string() }),
z.object({
type: z.literal("image"),
source: z.object({ type: z.enum(["url", "data"]), value: z.string() }),
}),
]);
const MessageSchema = z.object({
// `ModelMessage.role` is 'user' | 'assistant' | 'tool'. There is no
// 'system' role. Pass system instructions with the `systemPrompts`
// option of `chat()`.
role: z.enum(["user", "assistant", "tool"]),
content: z.union([z.string(), z.array(ContentPartSchema)]),
});
const BodySchema = z.object({
messages: z.array(MessageSchema),
threadId: z.string(),
});
// In an API route handler
const { messages, threadId } = BodySchema.parse(await request.json());
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
});The types of chat() still limit anything that you add at the call site to the input types of the selected model.