Provider URLs for generated media expire. A Sora clip, a batch of images, a long audio track: the model hands you a URL that stops working after a while, and once it does the output is gone. To keep the output, save the generated bytes to your own storage and serve them from your own origin, where they outlive the provider's link.
This is a server-side opt-in that layers on top of the generationRuns store Generation persistence already requires. Byte storage adds two more stores, which must be provided together:
What each choice gets you:
memoryPersistence() ships all three stores (generationRuns, artifacts, blobs), so it works out of the box. Any backend that implements ArtifactStore and BlobStore (see Build a generation adapter) works the same way.
The bytes land under the blob key artifacts/<runId>/<artifactId>. To fetch a generated file later (to render it, download it, or hand it to another request), add a GET route that reads the artifact back with the retrieveArtifact / retrieveBlob helpers and streams it from your own origin:
// routes/api.generate.image.ts, runs the generation.
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
retrieveArtifact,
retrieveBlob,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest(
'image',
request,
)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
// Persistence requires the scope these runs are filed under.
if (threadId === undefined) {
return new Response('`threadId` is required', { status: 400 })
}
const stream = generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
threadId,
stream: true,
middleware: [
withGenerationPersistence(persistence, {
// Stamp the durable serve URL (the artifact route below) onto every
// persisted artifact ref, and rewrite the live result's media field to
// it. Both the live and the restored result then render from your own
// origin instead of the provider's expiring link.
artifactUrl: (ref) =>
`/api/generate/image/artifact?id=${ref.artifactId}`,
}),
],
})
return toServerSentEventsResponse(stream)
}The serve route is a separate route from the generation endpoint. A GET on the generation route is already spoken for by Generation persistence, which is where a reloading client hydrates and where an in-flight run resumes, so the bytes get their own path, the one artifactUrl stamps above:
// routes/api.generate.image.artifact.ts, serves stored bytes by id.
//
// This is a plain file endpoint: it serves one stored file, it does not resume
// a run or rebuild a conversation.
//
// Security: the id comes from the caller, so this route MUST authorize before
// it serves. `ArtifactRecord` carries the `threadId` / `runId` the file was
// generated under. Check that against an identity you derive server-side from
// the session, never from the query string. Without this check, any caller who
// learns or guesses an artifact id can read another user's media.
export async function GET(request: Request) {
const artifactId = new URL(request.url).searchParams.get('id')
if (!artifactId) return new Response('missing id', { status: 400 })
const artifact = await retrieveArtifact(persistence, artifactId)
if (!artifact) return new Response('not found', { status: 404 })
// Replace with your session + ownership check, e.g.:
// const user = await auth(request)
// const owned = user != null && (await db.threadOwnedBy(user.id, artifact.threadId))
const owned = true
void request
// 404, not 403. A distinguishable "exists but forbidden" confirms valid ids.
if (!owned) return new Response('not found', { status: 404 })
const blob = await retrieveBlob(persistence, artifact)
if (!blob) return new Response('not found', { status: 404 })
return new Response(blob.body ?? (await blob.arrayBuffer()), {
headers: {
'content-type': artifact.mimeType,
'content-length': String(artifact.size),
},
})
}The route above is enough for images. Video is not: seeking a <video> is built on HTTP range requests, and a source that answers every Range with the whole file cannot be scrubbed: Safari refuses to play it at all, and every other browser downloads the entire clip before it starts. Pass range to retrieveBlob and answer 206:
import { parseRangeHeader } from '@tanstack/ai-persistence'
import type { ArtifactRecord } from '@tanstack/ai-persistence'
// The same route, seek-aware. `parseRangeHeader` resolves the header against
// the size on the record, including suffix ranges (`bytes=-500` is the LAST
// 500 bytes) and the unsatisfiable case. `blob.range` then reports the slice
// actually served, and `artifact.size` the whole file: the two numbers
// `Content-Range` needs.
export async function serveArtifactBytes(
request: Request,
artifact: ArtifactRecord,
) {
const range = parseRangeHeader(request.headers.get('range'), artifact.size)
if (range === 'unsatisfiable') {
return new Response('range not satisfiable', {
status: 416,
headers: { 'content-range': `bytes */${artifact.size}` },
})
}
const blob = await retrieveBlob(
persistence,
artifact,
range ? { range } : undefined,
)
if (!blob) return new Response('not found', { status: 404 })
const body = blob.body ?? (await blob.arrayBuffer())
// `accept-ranges` on every response, including the whole-file one: it is how
// the player learns it may seek at all.
const headers = {
'content-type': artifact.mimeType,
'accept-ranges': 'bytes',
}
if (!blob.range) {
return new Response(body, {
headers: { ...headers, 'content-length': String(artifact.size) },
})
}
const { offset, length } = blob.range
return new Response(body, {
status: 206,
headers: {
...headers,
'content-length': String(length),
'content-range': `bytes ${offset}-${offset + length - 1}/${artifact.size}`,
},
})
}The client side needs nothing special, which is the point. Given a route that answers ranges, the browser drives the rest:
import type { PersistedArtifactRef } from '@tanstack/ai'
export function GeneratedVideo({ artifact }: { artifact: PersistedArtifactRef }) {
// `artifact.url` is the app-origin URL `artifactUrl` stamped on the ref.
return <video src={artifact.url} controls preload="metadata" />
}preload="metadata" has the player fetch the header bytes with a range request and show the duration and scrubber without pulling the clip down. A store must support ranged reads for any of this to work; the conformance testkit asserts it.
memoryPersistence keeps everything in process memory, which is right for development and tests; point generationRuns / artifacts / blobs at a durable backend for production. Control what gets captured with withGenerationPersistence's extractArtifacts (return your own descriptors) and nameArtifact (name each file) options.
By default an artifact's bytes are written under artifacts/<runId>/<artifactId>. Pass storageKey to put them in your own folder structure instead, which helps when the bucket is shared with the rest of your app, or when you want media grouped by the thing it belongs to rather than by the run that produced it:
const storageKeyOptions = withGenerationPersistence(persistence, {
storageKey: ({ runId, artifactId, role, name }) =>
`products/${role}/${runId}-${artifactId}-${name}`,
})Two things worth knowing:
The resolved key is recorded on the artifact. Once the path is arbitrary it can no longer be recomputed from the record, so it is stored as ArtifactRecord.blobKey and reads resolve through it. Records written before this existed fall back to the default convention, so adding storageKey to an app with existing artifacts does not orphan them. It does mean the default convention can never be changed retroactively.
Returning a non-unique key overwrites. Include artifactId, or something equally unique, unless overwriting is what you want.
This is server-side only, deliberately. A key supplied by the browser would be a path-traversal and cross-tenant-write vector.
What gets stored is the generated output. When a provider returns an expiring link, the middleware downloads it and keeps the bytes, which is the whole point of this page.
Prompt media is different, and it splits by how you sent it:
Two reasons a caller-supplied URL is left alone. Downloading it server-side would let anyone name an address your server can reach (cloud metadata endpoints, localhost admin services) and then read the response back through the artifact GET route. The copy is also redundant: whoever supplied the URL already had the media.
If you do need a durable copy of caller-supplied media (a "paste an image URL" input box, say), opt in with allowInputUrl, which is a predicate rather than a flag precisely so the check is not optional:
const inputUrlOptions = withGenerationPersistence(persistence, {
allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),
})Every artifact fetch, input or output, is bounded three ways:
Input fetches add two more: a loopback / private / link-local host block, and a refusal to follow redirects, so a 302 cannot hop somewhere the check never saw.
Treat those as a backstop, not the control: a hostname that resolves to a private address still passes a literal-IP check. Keep allowInputUrl narrow, and for stronger isolation inject artifactFetch to route downloads through an egress-restricted proxy that can check the address actually connected to.
A provider URL is streamed into the blob store: the middleware never holds the artifact in memory, and size on the record is counted as the bytes drain. A 2 GB video costs a streaming store (R2, S3, a filesystem) the same memory as a 2 KB icon. memoryPersistence is the exception, and only because holding the bytes in process is what it does: it is a dev/test store, not a production one.
maxArtifactBytes is therefore a bound on transfer, not on memory. What it buys is a ceiling on what a runaway or hostile origin can make you pull and store: content-length is advisory, so an origin can declare 1 KB and send forever. That is the only reason there is a default at all.
Enforcing the cap during the drain means wrapping the body in a TransformStream that counts, and a transform's readable side carries no declared length, which is exactly what workerd's R2Bucket.put needs for a single-shot upload. So the wrapper is applied only where it is load-bearing:
| The response | What your store gets |
|---|---|
| declares a content-length, no content-encoding | the fetch body untouched, length intact |
| chunked, no declared length | the counting wrapper |
| content-encoding: gzip (declared length is compressed) | the counting wrapper |
In the first row nothing is lost by skipping the counter: the declared length was already checked against the cap, and HTTP framing holds the origin to it, a body cannot exceed a length it declared. That is the common case for a provider CDN, so on Cloudflare the default configuration already streams straight through:
// Inside your BlobStore.put on workerd: nothing buffered, no multipart, no
// hint needed. (`R2Bucket` comes from @cloudflare/workers-types.)
const putStraightToR2 = (bucket: R2Bucket, key: string, body: BlobBody) =>
bucket.put(key, body)The other two rows genuinely need the counter, because a chunked reply declares nothing, and a compressed one can decode to arbitrarily more than it declared (a decompression bomb). For those, BlobPutOptions.expectedLength is absent too, and a length-strict store falls back to a multipart upload that buffers one 8 MiB part at a time, so memory stays flat whatever the artifact's size. The ai-persistence/build-cloudflare-artifact-store skill ships that recipe.
To drop the ceiling entirely (no counter on any response, no limit on what an origin can stream into your bucket) pass false:
const uncappedOptions = withGenerationPersistence(persistence, {
maxArtifactBytes: false,
})Reach for that when you trust the origins you fetch from and would rather have no ceiling than a generous one. Your storage backend's own limits still apply, R2, for instance, caps a single-shot upload at 5 GiB and a multipart one at 10,000 parts. Keep the cap when allowInputUrl lets callers name the URL: there the origin is by definition not one you control.
artifactUrl is what makes the stored bytes reachable from the client without any extra plumbing. For each persisted ref it returns the app-origin URL that serves those bytes (the GET route above), and withGenerationPersistence does two things with it:
So the live result already points at your origin, not the provider's expiring link.
Those durable refs ride along on result.artifacts, and they are what a reload restores from. In Generation persistence, the generation hook rebuilds result from the persisted refs on mount, resolving each media field to its durable ref.url, so the restored result renders the same media the live run showed. result.artifacts is the whole artifact surface on the hook: there are no separate top-level artifact fields to read, live or restored.