Persistence (Advanced)
- 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)
Files API
Provider Files / storage APIs let you upload a media asset once and reference it later by a lightweight handle, instead of re-sending base64 (or relying on the provider to re-fetch a public URL) on every request. That means large or reused inputs are uploaded a single time — lower latency and bandwidth, no re-buffering of base64 on memory-constrained runtimes (e.g. Cloudflare Workers) — plus access to provider-side file lifecycle (TTL, deletion).
TanStack AI exposes this as a tree-shakeable files adapter per provider, paired with a { type: 'file' } content source you drop into a message.
Files adapters#
Each provider with a native surface has a factory: openaiFiles(), anthropicFiles(), geminiFiles(), grokFiles(), and falFiles(). They read the same credentials from the environment as the provider's other adapters. To pass a key explicitly, use the create*Files(apiKey) variants (createOpenaiFiles, createAnthropicFiles, createGeminiFiles, createGrokFiles). falFiles(config) takes its key in the config object. For a Bearer token, use createAnthropicFiles(token, { auth: 'bearer' }), see Bearer and OAuth tokens.
import { createOpenaiFiles, openaiFiles } from '@tanstack/ai-openai'
import { geminiFiles } from '@tanstack/ai-gemini'
import { anthropicFiles } from '@tanstack/ai-anthropic'
import { falFiles } from '@tanstack/ai-fal'
import { grokFiles } from '@tanstack/ai-grok'
const files = openaiFiles() // reads OPENAI_API_KEY
const filesWithKey = createOpenaiFiles('sk-your-key') // explicit keyuploadFile#
Drive an adapter with the uploadFile() activity function. It accepts a Blob (memory-efficient — preferred for large assets) or { data, mimeType } where data is base64, and returns a FileHandle:
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? }- id — the provider handle used for get / delete (OpenAI/Anthropic file_id, Gemini file resource name, fal storage URL).
- uri: the handle's URL form when the provider exposes one (Gemini file URI, fal storage URL, Grok public URL). It is undefined for OpenAI and Anthropic, whose handles are opaque ids.
- expiresAt — epoch milliseconds, when the provider schedules the handle to expire.
Runtime note (Gemini upload). geminiFiles().upload() uses @google/genai's resumable upload, which sets an explicit Content-Length header on a Blob-body request. Some server runtimes reject that with fetch failed / InvalidArgumentError: invalid content-length header. On TanStack Start / Nitro this fails on older Nitro (observed on nitro@3.0.1-alpha.2) and works on current Nitro (verified on nitro@3.0.260610-beta) — upgrade Nitro if you hit it. Native Node (and the production node-server build) are unaffected. OpenAI, Anthropic, and fal uploads use different transports and don't exercise this path.
getFile and deleteFile#
Providers with a lifecycle API support getFile() and deleteFile(). Both accept the handle itself (preferred — the handle's provider type rejects a foreign provider's handle at compile time) or its raw id:
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() defines no get / delete, and calling getFile() / deleteFile() with it throws a clear error.
Grok (xAI): handles are public URLs#
grokFiles() uploads to the xAI Files API, then mints a public URL for the stored object and uses that URL as the handle's reference.
xAI takes a file_id only on input_file (documents), and only on agentic-capable models. Its image path takes a URL. A public URL works for both, so one handle covers every modality 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/…' (wire reference)xAI limits to know:
- 50 MiB per file, and PNG, JPEG, MP4, or PDF only.
- expiresAfter runs from 3600 seconds (one hour) to 2592000 (thirty days).
- Up to 1000 active public URLs per team.
- Minting is idempotent, so re-uploading the same file returns the same URL.
To stop a URL resolving without deleting the file, call revokePublicUrl(handle.id) on the adapter. deleteFile() removes 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.
Referencing a handle in a message#
Use fileSourceFromHandle(handle) to turn a FileHandle into a { type: 'file' } content source:
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.
- provider names the adapter that issued it.
Each adapter maps value to its native wire field: OpenAI and Anthropic file_id, Gemini fileData.fileUri, fal storage URL, Grok public URL.
A handle only works with the provider that issued it. If provider names a different adapter, that adapter throws a clear error. Adapters that cannot consume file handles at all are rejected before any mapping starts.
Server: upload + reference#
import { chat, fileSourceFromHandle, uploadFile } from '@tanstack/ai'
import { anthropicFiles, anthropicText } from '@tanstack/ai-anthropic'
export async function askAboutPdf(pdfBase64: string, request: string) {
// Upload once; reuse the handle across turns.
const handle = await uploadFile({
adapter: anthropicFiles(),
input: { data: pdfBase64, mimeType: 'application/pdf' },
})
return chat({
adapter: anthropicText('claude-sonnet-5'),
messages: [
{
role: 'user',
content: [
{ type: 'text', content: request },
{ type: 'document', source: fileSourceFromHandle(handle) },
],
},
],
})
}Client: reuse a handle across requests#
Upload needs the provider key, so it happens on the server. The browser keeps the handle it gets back and puts it in the message on each turn.
- Store the handle the upload endpoint returned. Keep { id, provider, uri, mimeType }.
- Build the source with fileSourceFromHandle(handle) and put it in the message content.
- On the server, pass the messages to chat() as usual.
In the browser, import fileSourceFromHandle from @tanstack/ai/client. That entry does not load server code:
import { useChat } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { fileSourceFromHandle } from '@tanstack/ai/client'
import type { FileHandle } from '@tanstack/ai/client'
// `handle` came from your upload endpoint and is stored client-side.
function AskAboutFile({ handle }: { handle: FileHandle }) {
const { sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
return (
<button
onClick={() =>
sendMessage({
content: [
{ type: 'text', content: 'Describe this' },
{ type: 'image', source: fileSourceFromHandle(handle) },
],
})
}
type="button"
>
Ask
</button>
)
}On the server, the file part arrives in the messages. Use a text adapter from the provider that issued the handle:
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import type { ModelMessage } from '@tanstack/ai'
export function runTurn(messages: Array<ModelMessage>) {
return chat({ adapter: openaiText('gpt-5.6'), messages })
}A handle from another provider throws before any request. For example, an OpenAI handle sent to a Gemini adapter fails in preflight. It is never dropped and never sent as a URL.
Provider support#
| Provider | Adapter | Handle referenced 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 round-trip through a plain { type: 'url' } source; OpenAI and Anthropic handles are opaque ids that require the { type: 'file' } source.
Providers and endpoints that can't consume references#
Adapters that can consume file references declare a supportsFileSources capability; for everyone else (Groq, Bedrock, Mistral, OpenRouter, Ollama, BytePlus, Cohere, and any adapter written before this feature existed) chat() / generateImage() / generateVideo() / embed() reject { type: 'file' } sources before any request is built, so a reference can never be silently mis-mapped onto a URL or data field.
Some endpoints on supporting providers also have no "reference an uploaded handle" option — OpenAI's images/edits and Sora input_reference, and Gemini's Veo, need the actual bytes (or, for Veo, a gs:// URI). The OpenAI Chat Completions image path also references images only by URL/data URI, not file_id — use the Responses adapter (openaiText) for file_id images. These throw a clear endpoint-specific error.