- 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
Files API
The files API uploads a file once and sends the file id on later turns. Each request then skips a fresh base64 upload of the same PDF.
Quick setup#
This setup uploads a PDF to Anthropic and asks Claude about it. It takes about 10 minutes. Set ANTHROPIC_API_KEY on the server first.
1. Add an upload route#
The upload needs the provider key, so it runs on the server. uploadFile takes a Blob and returns a FileHandle:
// api/upload.ts
import { uploadFile } from "@tanstack/ai";
import { anthropicFiles } from "@tanstack/ai-anthropic";
export async function POST(request: Request) {
const form = await request.formData();
const [file] = form.getAll("file");
if (!(file instanceof Blob)) {
return new Response("Missing file", { status: 400 });
}
const handle = await uploadFile({ adapter: anthropicFiles(), input: file });
return Response.json(handle);
}2. Keep the handle in the browser and send it#
Store the handle that the upload route returns. Then put it in the message with fileSourceFromHandle. Import it from @tanstack/ai/client, because that entry does not load server code:
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { fileSourceFromHandle } from "@tanstack/ai/client";
import type { FileHandle } from "@tanstack/ai/client";
export function AskAboutPdf() {
const [handle, setHandle] = useState<FileHandle | null>(null);
const { sendMessage } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
});
async function upload(file: File) {
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/upload", { method: "POST", body });
const uploaded: FileHandle = await response.json();
setHandle(uploaded);
}
return (
<>
<input
type="file"
accept="application/pdf"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void upload(file);
}}
/>
<button
type="button"
disabled={!handle}
onClick={() => {
if (!handle) return;
void sendMessage({
content: [
{ type: "text", content: "Summarize this PDF" },
{ type: "document", source: fileSourceFromHandle(handle) },
],
});
}}
>
Ask
</button>
</>
);
}3. Use the same provider in the chat route#
The file part arrives in the messages. Use a text adapter from the provider that issued the handle:
// api/chat.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages,
threadId,
runId,
});
return toServerSentEventsResponse(stream);
}Pick a PDF, click Ask, and Claude answers about it. Ask again with the same handle, and no bytes go up a second time.
How it works#
- uploadFile sends the bytes to the provider and returns a FileHandle.
- fileSourceFromHandle(handle) turns the handle into a { type: 'file' } content source.
- The adapter maps that source to the field of its provider, for example file_id on Anthropic.
- A handle works only with the provider that issued it. If you send it to another provider, the adapter throws before any request goes out.
Advanced#
Files adapters#
Each provider with a Files API has a factory:
| Provider | Factory | Key from |
|---|---|---|
| OpenAI | openaiFiles() or createOpenaiFiles(apiKey) | OPENAI_API_KEY, or the argument |
| Anthropic | anthropicFiles() or createAnthropicFiles(apiKey) | ANTHROPIC_API_KEY, or the argument |
| Gemini | geminiFiles() or createGeminiFiles(apiKey) | the same env as the Gemini adapters, or the argument |
| Grok (xAI) | grokFiles() or createGrokFiles(apiKey) | the same env as the Grok adapters, or the argument |
| fal | falFiles(config) | the config object |
For a Bearer token, use createAnthropicFiles(token, { auth: 'bearer' }). See Bearer and OAuth tokens.
import { createOpenaiFiles, openaiFiles } from "@tanstack/ai-openai";
const files = openaiFiles(); // reads OPENAI_API_KEY
const filesWithKey = createOpenaiFiles("sk-your-key");Upload input#
uploadFile accepts one of these:
- A Blob. This uses less memory, so it is better for large files.
- { data, mimeType, filename? }, where data is base64.
import { uploadFile } from "@tanstack/ai";
import { openaiFiles } from "@tanstack/ai-openai";
import { pdfBase64 } from "./pdf-data";
const handle = await uploadFile({
adapter: openaiFiles(),
input: { data: pdfBase64, mimeType: "application/pdf" },
});
// handle: { id, provider, uri?, mimeType?, sizeBytes?, expiresAt?, filename? }The fields of the handle:
- id: the provider handle for get and delete. This is the OpenAI or Anthropic file_id, the Gemini file resource name, or the fal storage URL.
- uri: the URL form of the handle, when the provider has one (Gemini file URI, fal storage URL, Grok public URL). It is undefined for OpenAI and Anthropic, because their handles are opaque ids.
- expiresAt: epoch milliseconds, when the provider plans to expire the handle.
getFile and deleteFile#
Providers with a lifecycle API support getFile() and deleteFile(). Both take the handle or its raw id. If you can, pass the handle. Its provider type rejects a handle from another provider at compile time.
import { deleteFile, getFile, uploadFile } from "@tanstack/ai";
import { openaiFiles } from "@tanstack/ai-openai";
import { pdfBase64 } from "./pdf-data";
const files = openaiFiles();
const handle = await uploadFile({
adapter: files,
input: { data: pdfBase64, mimeType: "application/pdf" },
});
const meta = await getFile({ adapter: files, id: handle });
await deleteFile({ adapter: files, id: handle });fal storage is upload-only. falFiles() has no get or delete, so getFile() and deleteFile() throw with it.
Grok (xAI): handles are public URLs#
grokFiles() uploads to the xAI Files API. Then it mints a public URL for the file and uses that URL as the reference of the handle.
xAI takes a file_id only on input_file (documents), and only on agentic models. Its image path takes a URL. A public URL works for both, so one handle works for every input type on every chat model.
import { uploadFile } from "@tanstack/ai";
import { grokFiles } from "@tanstack/ai-grok";
import { pngBase64 } from "./image-data";
const handle = await uploadFile({
// Omit `expiresAfter` for a URL that does not expire.
adapter: grokFiles({ expiresAfter: 86_400 }),
input: { data: pngBase64, mimeType: "image/png" },
});
// handle.id -> 'file_abc123' (lifecycle)
// handle.uri -> 'https://files-cdn.x.ai/...' (sent in the request)xAI limits:
- 50 MiB for each file. PNG, JPEG, MP4, or PDF only.
- expiresAfter is from 3600 seconds (one hour) to 2592000 (thirty days).
- Up to 1000 active public URLs for each team.
- Minting is idempotent. If you upload the same file again, you get the same URL.
To stop a URL without deleting the file, call revokePublicUrl(handle.id) on the adapter. deleteFile() deletes the file itself.
getFile() mints the public URL to put it on the handle. After you revoke a URL, do not call getFile() for that file, or the file gets a public URL again.
The file source#
fileSourceFromHandle(handle) returns this shape. You can also write it yourself:
import type { ContentPartFileSource } from "@tanstack/ai";
const source: ContentPartFileSource = {
type: "file",
value: "file-abc",
provider: "openai",
};- value is the handle exactly as the provider issued it. Treat it as opaque. fileSourceFromHandle uses uri when the handle has one, and id if not.
- provider names the adapter that issued it.
You can also upload and send in one server function:
import { chat, fileSourceFromHandle, uploadFile } from "@tanstack/ai";
import { anthropicFiles, anthropicText } from "@tanstack/ai-anthropic";
export async function askAboutPdf(
pdfBase64: string,
request: string,
threadId: string,
) {
// Upload one time. Use the handle again on later turns.
const handle = await uploadFile({
adapter: anthropicFiles(),
input: { data: pdfBase64, mimeType: "application/pdf" },
});
return chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages: [
{
role: "user",
content: [
{ type: "text", content: request },
{ type: "document", source: fileSourceFromHandle(handle) },
],
},
],
threadId,
});
}Provider support#
| Provider | Adapter | Handle sent as | Lifecycle |
|---|---|---|---|
| OpenAI | openaiFiles() | Responses input_image / input_file file_id | get, delete |
| Anthropic | anthropicFiles() | file_id message source (sends the files-api-2025-04-14 beta) | get, delete |
| Gemini | geminiFiles() | fileData.fileUri (the handle URI) | get, delete |
| fal | falFiles() | storage URL (used like any URL) | upload-only |
| Grok (xAI) | grokFiles() | public URL (used like any URL) | get, delete |
Gemini and fal handles are URLs, so they also work in a plain { type: 'url' } source. OpenAI and Anthropic handles are opaque ids, so they need the { type: 'file' } source.
Providers and endpoints that cannot use a handle#
Adapters that can use file handles declare a supportsFileSources capability. For all other adapters, chat(), generateImage(), generateVideo(), and embed() reject a { type: 'file' } source before they build a request. This applies to:
- Groq, Bedrock, Mistral, OpenRouter, Ollama, BytePlus, and Cohere.
- Any adapter that does not declare supportsFileSources.
So a handle never goes out by mistake as a URL or as data.
Some endpoints of supported providers also need the bytes, and throw an error for that endpoint:
- OpenAI images/edits and the Sora input_reference need the bytes.
- Gemini Veo needs the bytes or a gs:// URI.
- The image path of OpenAI Chat Completions takes only a URL or a data URI. Use the Responses adapter (openaiText) for file_id images.
Gemini upload on some server runtimes#
geminiFiles().upload() uses the resumable upload of @google/genai. It sets a Content-Length header on a request with a Blob body. Some server runtimes reject that header with fetch failed or InvalidArgumentError: invalid content-length header.
- On TanStack Start or Nitro, older Nitro fails (seen on nitro@3.0.1-alpha.2). Current Nitro works (tested on nitro@3.0.260610-beta). If you get this error, upgrade Nitro.
- Native Node and the production node-server build work.
- OpenAI, Anthropic, and fal uploads use a different transport and do not hit this error.