# Keep Generated Files

# Keep Generated Files

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](./generation-persistence) already requires. Byte storage
adds two more stores, which must be provided together:

- an `artifacts` store: the metadata.
- a `blobs` store: the bytes.

What each choice gets you:

- **Both provided**: `withGenerationPersistence` writes each generated file's
  bytes to the blob store, records an `ArtifactRecord`, and attaches durable
  references to the result and the run record.
- **Neither provided**: only the run record is kept.

`memoryPersistence()` ships all three stores (`generationRuns`, `artifacts`,
`blobs`), so it works out of the box. Any backend that implements `ArtifactStore`
and `BlobStore` (see
[Build your own adapter](./build-your-own-adapter#generation--media-stores))
works the same way.

## Serve the stored bytes

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:

```ts group=generation-bytes
// 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](./generation-persistence) — that 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:

```ts group=generation-bytes
// 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),
    },
  })
}
```

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

## Choose where the bytes land

By default an artifact's bytes are written under
`artifacts/<runId>/<artifactId>`. Pass `storageKey` to put them in your own
folder structure instead — useful 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:

```ts group=generation-bytes
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 — but 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.

## Prompt media referenced by URL

What gets stored is the **generated output**. When a provider returns an
expiring link, the middleware downloads it and keeps the bytes — that is the
whole point of this page.

Prompt media is different, and it splits by how you sent it:

- **base64** (`source: { type: 'data' }`): stored alongside the output, because
  the bytes are already in hand.
- **a URL** (`source: { type: 'url' }`): **not fetched**, and no artifact is
  recorded for 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:

```ts group=generation-bytes
const inputUrlOptions = withGenerationPersistence(persistence, {
  allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),
})
```

Every artifact fetch, input or output, is bounded three ways:

- The scheme must be `http:` or `https:`.
- It is aborted after `artifactFetchTimeoutMs` (default 30s).
- It is capped at `maxArtifactBytes` (default 100 MiB) as the body drains.

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.

## Wire the durable URL through to the client

`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:

- Stamps the URL onto the ref, as `ref.url`.
- Rewrites the live result's media field to the same URL: `result.images[i].url`
  for images, `result.url` for a video, `result.audio.url` for audio.

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](./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.

## Where to go next

- [Generation persistence](./generation-persistence): the run record that
  survives a reload or a dropped connection, and the `generationRuns` store that
  byte storage builds on.
- [Build your own adapter](./build-your-own-adapter#generation--media-stores): a
  custom `ArtifactStore` / `BlobStore` on your own database.
