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.
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.