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.
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.
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);
}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>
</>
);
}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.
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");uploadFile accepts one of these:
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:
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.
uploadFile(), getFile(), and deleteFile() take an abortSignal. When it aborts, the call rejects. The adapter passes the signal to the provider request when the provider SDK can cancel it:
import { uploadFile } from "@tanstack/ai";
import { openaiFiles } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const handle = await uploadFile({
adapter: openaiFiles(),
input: await request.blob(),
abortSignal: request.signal,
});
return Response.json(handle);
}The Gemini and fal SDKs do not cancel a running upload. With those providers, the call still rejects at once, but the upload can finish on the provider.
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:
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.
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",
};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 | 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.
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:
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:
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.