Persistence (Advanced)
- Build your first harness
- Host options
- Send and show media
- Connect clients
- Write a client in any language
- Access and tenants
- Run in the terminal
- Deploy a harness
- Send inputs safely
- Run a tool without a model
- Durable sessions
- Run background work on workers
- List, rename, and fork sessions
- Lifecycle and retention
- Run side effects once
- Wait for a person
- Build on the session log
- Control how a turn ends
- Store settings per thread
- Compact a long session
- Fork and reset a thread
- One log for a session tree
- Write a plugin
- Track usage and cost
- Ask before risky tool calls
- Give your agent coding tools
- Sandbox and extend the coding tools
- Run the coding tools for many threads
- Undo a turn
- Build a coding agent
- Delegate to coding agents
- Switch between agent profiles
- Auth and connectors
- Share a thread between people
- Connect model providers
- Build your own UI
- Code mode in a harness
- Work until a goal is met
- Run agents from a harness
- Hand off a conversation
- Add skills
- Use MCP servers
- Give your agent a computer
- Use from any MCP client
- Use a harness from an editor (ACP)
- Overview
- Quick Start
- Providers
- Harnesses
- Harness Auth
- Workspace
- Tools
- Policy
- Lifecycle & Snapshots
- Portable Snapshots
- Keep Files After Reload
- Save a Named Version
- Branch From a Version
- Send a Frozen File
- Let the Agent Save and Fork
- Pick Which Files to Keep
- What a Snapshot Stores
- Instance Durability
- Durable Runs
- Events
Sandboxes (Advanced)
- Runtime Context
- Bring Your Own Key
- Keep Keys on the Server
- Debug Logging
- Multimodal Content
- Files API
- Per-Model Type Safety
- Runtime Adapter Switching
- Tree-Shaking
- Extend Adapter
- Extend Adapter: Advanced
- Typed Pre-Configured Options
- Test with a Fake Model
- Prompt Caching
- Mid-Conversation Changes
- Approval Flow Processing
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.