Copy generated media into your store before the provider URL expires. A Sora clip, an image batch, or a long audio track otherwise stops loading.
To keep the output, save the generated bytes to your own storage and serve them from your own origin.
This adds to Generation persistence. Set that up first.
Byte storage needs two stores, and you must provide both:
memoryPersistence() has both, plus generationRuns. Any backend that implements ArtifactStore and BlobStore works the same way. See Build a generation adapter.
Pass artifactUrl to withGenerationPersistence. It returns the URL of the serve route from step 3:
// 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.')
}
const stream = generateImage({
adapter: openaiImage('gpt-image-2.5-flare'),
prompt: input.prompt,
threadId,
stream: true,
middleware: [
withGenerationPersistence(persistence, {
// Stamp the serve URL (the artifact route below) on every stored
// artifact ref. The live result and the restored result then both
// load from your own origin, not from the expiring provider link.
artifactUrl: (ref) =>
`/api/generate/image/artifact?id=${ref.artifactId}`,
}),
],
})
return toServerSentEventsResponse(stream)
}Use a separate route. The GET on the generation route already answers hydration and resume for Generation persistence:
// routes/api.generate.image.artifact.ts, serves stored bytes by id.
//
// Security: the id comes from the caller, so this route MUST authorize before
// it serves. `ArtifactRecord` has the `threadId` and `runId` that the file was
// generated under. Compare them with an identity from the server session,
// never from the query string. Without this check, any caller who learns or
// guesses an artifact id can read the media of another user.
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 and ownership check, for example:
// 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 separate "exists but forbidden" answer 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),
},
})
}You do not change the client. result.images[i].url already points at your route. Generate an image, reload the page, and the image still loads after the provider link expires.
With both stores present, withGenerationPersistence does this for each generated file:
Without the two stores, only the run record is kept.
artifactUrl makes the stored bytes reachable from the client. For each stored ref, it returns the URL on your origin that serves the bytes. Then withGenerationPersistence does two things with it:
The durable refs travel on result.artifacts. On mount, the generation hook rebuilds result from these refs and sets each media field to its ref.url. So the restored result shows the same media as the live run. result.artifacts is the full artifact surface on the hook. There are no other top-level artifact fields, live or restored.
memoryPersistence keeps everything in process memory. That is correct for development and tests. For production, point generationRuns, artifacts, and blobs at a durable backend.
The route above is enough for images, but not for video. Seeking in a <video> uses HTTP range requests. If a source answers every Range with the whole file, the user cannot seek. Safari does not play it at all, and other browsers download the full clip before it starts.
Pass range to retrieveBlob and answer with 206:
import { parseRangeHeader } from '@tanstack/ai-persistence'
import type { ArtifactRecord } from '@tanstack/ai-persistence'
// The same route, with seek support. `parseRangeHeader` resolves the header
// against the size on the record. It handles suffix ranges (`bytes=-500` is the
// LAST 500 bytes) and the unsatisfiable case. `blob.range` then reports the
// slice that was served, and `artifact.size` the whole file: the two numbers
// that `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` goes on every response, also the whole-file one. It tells
// the player that it can seek.
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 needs nothing special. When the route answers ranges, the browser does the rest:
import type { PersistedArtifactRef } from '@tanstack/ai'
export function GeneratedVideo({ artifact }: { artifact: PersistedArtifactRef }) {
// `artifact.url` is the URL on your origin that `artifactUrl` stamped on the ref.
return <video src={artifact.url} controls preload="metadata" />
}With preload="metadata", the player fetches the header bytes with a range request. It shows the duration and the seek bar without a download of the full clip. The store must support ranged reads for this to work. The conformance testkit checks it.
Two options of withGenerationPersistence control the capture:
If you use a video provider with no public URL and a custom extractArtifacts, return a descriptor with bytes: result.body for the video. If you do not, the stream is not stored, and url is a base64 data: URL.
By default, the bytes go under artifacts/<runId>/<artifactId>. Pass storageKey to use your own folder structure. This helps when your app shares the bucket, or when you want media grouped by the thing it belongs to, not by the run:
const storageKeyOptions = withGenerationPersistence(persistence, {
storageKey: ({ runId, artifactId, role, name }) =>
`products/${role}/${runId}-${artifactId}-${name}`,
})The middleware stores the generated output. When a provider returns a link that expires, the middleware downloads it and keeps the bytes.
Prompt media is different. It depends on how you sent it:
The middleware leaves a caller URL alone for two reasons:
If you need a durable copy of caller media (for example a "paste an image URL" input), opt in with allowInputUrl. It is a predicate, not a flag, so you must write the check:
const inputUrlOptions = withGenerationPersistence(persistence, {
allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),
})Every artifact fetch, input or output, has three limits:
Input fetches add two more limits:
These limits are a backstop, not the main control. A hostname that resolves to a private address still passes a literal-IP check. Keep allowInputUrl narrow. For stronger isolation, inject artifactFetch to send downloads through an egress-restricted proxy. That proxy can check the address that the connection actually reached.
The middleware streams a provider URL into the blob store. It never holds the artifact in memory, and it counts size on the record 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, because it keeps the bytes in process by design. It is a store for development and tests, not for production.
So maxArtifactBytes limits transfer, not memory. It puts a ceiling on what a runaway or hostile origin can make you download and store. content-length is only a claim: an origin can declare 1 KB and send data without end. That is the only reason for the default.
Some video providers return the finished video as a stream, with no URL to fetch. See Videos with no public URL. The middleware passes that stream to BlobStore.put as it is, so memory stays flat here too.
There are two differences from a fetched URL:
To enforce the cap during the drain, the middleware wraps the body in a TransformStream that counts. The readable side of a transform has no declared length, and the workerd R2Bucket.put needs that length for a single-shot upload. So the middleware adds the wrapper only where it is necessary:
| The response | What your store gets |
|---|---|
| Declares a content-length, no content-encoding | The fetch body untouched, with its length |
| Chunked, no declared length | The counting wrapper |
| content-encoding: gzip (the declared length is compressed) | The counting wrapper |
In the first row, the counter is not necessary. The declared length was already checked against the cap, and HTTP framing holds the origin to it. That is the common case for a provider CDN. So on Cloudflare, the default configuration 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 need the counter:
For those rows, BlobPutOptions.expectedLength is also absent. A store that needs a length then uses a multipart upload. That upload buffers one 8 MiB part at a time, so memory stays flat for any artifact size. The ai-persistence/build-cloudflare-artifact-store skill has that recipe.
To remove the ceiling (no counter on any response, no limit on what an origin can stream into your bucket), pass false:
const uncappedOptions = withGenerationPersistence(persistence, {
maxArtifactBytes: false,
})Use this when you trust the origins that you fetch from. The limits of your storage backend still apply. For example, R2 caps a single-shot upload at 5 GiB and a multipart upload at 10,000 parts.
Keep the cap when allowInputUrl lets callers name the URL. Then you do not control the origin.