Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
b3fe626
feat(persistence): client-side generation persistence
AlemTuzlak Jul 23, 2026
5d4c721
docs(persistence): drop generic from generation snapshot store example
AlemTuzlak Jul 23, 2026
5813491
refactor(persistence): align generation persistence API with chat
AlemTuzlak Jul 23, 2026
ad704da
refactor(persistence): infer generation store type via GenerationPers…
AlemTuzlak Jul 23, 2026
19a9fe9
refactor(persistence): value-agnostic web-storage adapter defaults
AlemTuzlak Jul 23, 2026
415627a
docs(persistence): fix stale generation-persistence delivery guidance
AlemTuzlak Jul 23, 2026
4a2b345
docs(persistence): rewrite generation-persistence for clarity + when-…
AlemTuzlak Jul 23, 2026
1398899
docs(persistence): drop redundant storage-adapter comment
AlemTuzlak Jul 23, 2026
946fb16
fix(persistence): generation snapshot lifecycle, hydration, StrictMod…
tombeckenham Jul 28, 2026
81aa215
fix(persistence): docs, example, changeset, React hooks, and real tes…
tombeckenham Jul 28, 2026
eae47bb
test(persistence): E2E reload-and-rehydrate spec for generation snaps…
tombeckenham Jul 28, 2026
72220ea
docs(skills): cover generation resume snapshots in client-persistence…
tombeckenham Jul 28, 2026
d668114
fix(persistence): framework sweeps for Solid/Vue/Svelte/Angular + hyd…
tombeckenham Jul 28, 2026
a92bd99
ci: apply automated fixes
autofix-ci[bot] Jul 28, 2026
65269ea
refactor(ai-react): thread TInput through UseGenerationReturn, drop g…
tombeckenham Jul 28, 2026
bdb2e89
refactor: thread TInput through generation return types in solid/vue/…
tombeckenham Jul 28, 2026
d1a56aa
refactor(persistence): restore typed storage-adapter defaults (drop T…
tombeckenham Jul 28, 2026
a9c4c19
feat(persistence): durable generation media-byte storage
AlemTuzlak Jul 28, 2026
a9421b9
feat(persistence): two-mode generation persistence + GenerationJobStore
AlemTuzlak Jul 28, 2026
ff17c96
ci: apply automated fixes
autofix-ci[bot] Jul 28, 2026
37a615a
docs(persistence): route readers to generation persistence + split by…
AlemTuzlak Jul 28, 2026
336ec96
refactor(generation-persistence): restore transparently into the norm…
AlemTuzlak Jul 28, 2026
11db2e0
ci: apply automated fixes
autofix-ci[bot] Jul 28, 2026
9c7f3dc
docs(persistence): slim the generation page, move advanced material t…
AlemTuzlak Jul 28, 2026
f976c90
feat(generation-persistence): rejoin an in-flight run on mount (useCh…
AlemTuzlak Jul 28, 2026
709032e
ci: apply automated fixes
autofix-ci[bot] Jul 28, 2026
61b8d07
docs(persistence): remove the generation-persistence advanced page
AlemTuzlak Jul 28, 2026
7fe9796
feat(examples): shared generation run history + fix stale generation-…
tombeckenham Jul 29, 2026
3ece807
refactor(persistence): rename generation job to run (GenerationRunSto…
tombeckenham Jul 29, 2026
9e4f1bb
docs: explain threads, runs, and turns across streaming, interrupts, …
tombeckenham Jul 29, 2026
20d5942
docs: narrow streaming guide to threads and runs, cross-link from per…
tombeckenham Jul 29, 2026
b6b9fa6
refactor(examples): drop generation run history, switch image/video t…
tombeckenham Jul 29, 2026
3df8aba
ci: apply automated fixes
autofix-ci[bot] Jul 29, 2026
7d87a30
fix(persistence): don't fetch caller-supplied prompt URLs + review fixes
tombeckenham Jul 29, 2026
951030f
refactor(persistence): rename artifact externalUrl to sourceUrl
tombeckenham Jul 29, 2026
9c73d86
feat(generation-persistence): require threadId when persistence is on
tombeckenham Jul 29, 2026
2358c1d
fix(persistence): fail loudly when a threadId lookup needs findLatest…
tombeckenham Jul 29, 2026
14b8d00
feat(persistence): storageKey for blob paths, and blobKey on the record
tombeckenham Jul 29, 2026
4c0a9b4
feat(persistence): server-side generation persistence in the example;…
tombeckenham Jul 29, 2026
08ab3f7
fix(example): keep generation persistence across HMR re-evaluation
tombeckenham Jul 29, 2026
ec23ae3
feat(persistence): sqlite generation stores + conformance coverage
tombeckenham Jul 29, 2026
a94ef8c
feat(example): server-side generation persistence on every activity
tombeckenham Jul 29, 2026
1ac59f1
fix(example): make generation routes resumable so a refresh can rejoin
tombeckenham Jul 29, 2026
b9e726b
fix(example): let nitro's dev middleware serve /api to subresources
tombeckenham Jul 29, 2026
420a9da
feat(persistence): server-driven generation persistence over server f…
tombeckenham Jul 29, 2026
bb24b70
Merge remote-tracking branch 'origin/main' into feat/generation-persi…
tombeckenham Jul 29, 2026
02d9ad1
fix(ai): apply result transforms and carry identity in streaming gene…
tombeckenham Jul 29, 2026
740dea2
feat(persistence)!: require threadId on withGenerationPersistence
tombeckenham Jul 29, 2026
7061f14
fix(generation): make runs survive client disconnect and resume mid-run
AlemTuzlak Jul 29, 2026
beca80d
feat(example): persistent-generation route; rely on library durability
AlemTuzlak Jul 29, 2026
11f98a2
docs(resumable-streams): durable producer survives a client disconnect
AlemTuzlak Jul 29, 2026
c424b4b
ci: apply automated fixes
autofix-ci[bot] Jul 29, 2026
281e241
fix(docs): generation hydration server fn returns a Response so kiira…
AlemTuzlak Jul 29, 2026
f9001e3
test(e2e): durable generation survives a mid-run reload
AlemTuzlak Jul 29, 2026
69c1d5f
test(e2e): make the mid-run rejoin deterministic and self-proving
AlemTuzlak Jul 29, 2026
8df68ca
feat(hooks)!: expose runId instead of resumeState
AlemTuzlak Jul 30, 2026
7f48d80
docs(persistence): add the id map, and use lists over run-on paragraphs
AlemTuzlak Jul 30, 2026
2a9be2a
docs: drop em dashes from the prose added in this branch
AlemTuzlak Jul 30, 2026
f78ba5a
docs(persistence): open the adapter guide with a store picker table
AlemTuzlak Jul 30, 2026
daa9fef
refactor(ai-persistence)!: one status vocabulary for both run stores
AlemTuzlak Jul 30, 2026
1e5466e
fix(ai)!: take the generation scope from the activity, not the middle…
AlemTuzlak Jul 30, 2026
e68a15f
refactor(generation)!: drop client-side generation persistence
AlemTuzlak Jul 30, 2026
caa3e52
fix(generation): persist image server-fns; deprecate dual id
tombeckenham Jul 30, 2026
4bd1a4d
ci: apply automated fixes
autofix-ci[bot] Jul 30, 2026
bcd465c
refactor(persistence)!: require threadId on a generation run record
tombeckenham Jul 31, 2026
a6e36a4
fix(generation): close the review findings across persistence, client…
tombeckenham Jul 31, 2026
8f2305e
ci: apply automated fixes
autofix-ci[bot] Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/generation-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@tanstack/ai': minor
'@tanstack/ai-utils': minor
'@tanstack/ai-persistence': minor
'@tanstack/ai-client': minor
'@tanstack/ai-event-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-vue': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-angular': minor
---

Add generation persistence, mirroring chat: media generation runs survive a reload or dropped connection, restoring transparently into the normal hook fields, with optional durable storage of the generated bytes.

**Generation run store (server).** `withGenerationPersistence` records each run in a dedicated `generationRuns` (`GenerationRunStore`) store, keyed by the run's own `runId` (the same AG-UI run id the client sends), with `threadId` the run's scope β€” it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `generationRuns` store, and `defineGenerationRunStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do.

**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper β€” the generation parallel of `reconstructChat` β€” reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. `authorize` is optional at the type level for single-user and prototype routes, but any multi-user deployment must pass it: the run and thread ids arrive from the caller, so identity has to be derived from server-side session state and ownership checked before the helper reads persistence. The same applies to a route that serves artifact bytes by id.

**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts/<runId>/<artifactId>`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. Prompt media referenced by **URL** is not downloaded: the URL is caller-supplied, so fetching it server-side would be an SSRF vector, and the bytes are redundant. Opt in per-app with `allowInputUrl` (a predicate, so the check can't be skipped). Every artifact fetch is limited to `http:`/`https:`, timed out (`artifactFetchTimeoutMs`, default 30s) and size-capped (`maxArtifactBytes`, default 100 MiB); input fetches additionally block loopback/private/link-local hosts and refuse redirects. `artifactFetch` injects the `fetch` used, for routing downloads through an egress-restricted proxy. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`.

**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven β€” cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven β€” write a lightweight snapshot under `generation:<threadId>` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity β€” there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. With byte storage configured, a restored `result` is rebuilt whole, its media resolved to the durable serve URL and its refs on `result.artifacts`; without it, `status` / `error` restore and `result` stays null. The snapshot never holds the generated bytes and never restarts provider work β€” generation still only begins on `generate(...)`.

**`threadId` is required whenever `persistence` is set**, in both modes, enforced at the type level. It is the generation's _scope_ β€” a stable, app-chosen name for the slot successive runs fill (`product-123-hero`, `video-9-start-frame`) β€” not a link to a chat conversation, so a workflow generating media outside any conversation names it just as naturally. It stays optional for ephemeral generations, so existing call sites that do not opt into persistence are unaffected. Persistence keys on it in both modes; `id` is the devtools/instance label only and never keys storage. Previously the key fell back to `id` and then to a generated id, which silently wrote a different slot on every reload β€” restoring nothing while orphaning the last record.

**Choose where bytes land.** `withGenerationPersistence`'s new `storageKey` option maps each artifact to its blob-store key, so generated media can live in your own folder structure instead of the default `artifacts/<runId>/<artifactId>`. Server-side only β€” a browser-supplied key would be a path-traversal and cross-tenant-write vector. The resolved key is recorded on the new `ArtifactRecord.blobKey` (it is no longer derivable once arbitrary) and reads resolve through `resolveArtifactBlobKey`; records written before the field existed fall back to the default convention, so it is a non-breaking addition.

`reconstructGeneration` now throws, rather than silently returning no run, when a `?threadId=` lookup is made against a `generationRuns` store that does not implement the optional `findLatestForThread`. A `?runId=` lookup never needs it and still works on a minimal adapter.

The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too with no type argument (a bare call is correct on either side). Untrusted snapshots are validated with the new `parseGenerationResumeSnapshot`.
40 changes: 40 additions & 0 deletions docs/chat/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,46 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction)

> **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content).

### Threads and runs

Two ids frame every stream, and they come from the AG-UI protocol itself β€” not
from any storage layer:

- A **thread** (`threadId`) is the conversation: the stable identity across
every exchange, reload, and device.
- A **run** (`runId`) is one execution inside it: everything between one
`RUN_STARTED` and its `RUN_FINISHED` (or `RUN_ERROR`). Every start mints a
fresh run id, so a thread accumulates many runs over its life.

A run is not limited to a single model response. Tool calls and their
follow-up responses stream inside the same run β€” the whole
[agentic cycle](./agentic-cycle), however many loops it takes, is one run:

```mermaid
flowchart LR
subgraph thread ["Thread β€” threadId (stable)"]
direction LR
subgraph r1 ["Run r1 β€” finished"]
direction TB
e1["RUN_STARTED β†’ text β†’ tool call β†’ tool result β†’ final text β†’ RUN_FINISHED"]
end
subgraph r2 ["Run r2 β€” finished"]
direction TB
e2["RUN_STARTED β†’ text β†’ RUN_FINISHED"]
end
subgraph r3 ["Run r3 β€” running"]
direction TB
e3["RUN_STARTED β†’ text"]
end
r1 --> r2 --> r3
end
```

Because run ids are ephemeral, anything long-lived anchors on the thread:
[resumable streams](../resumable-streams/overview) log delivery per `runId`,
while [server persistence](../persistence/chat-persistence#threads-runs-and-turns)
stores the transcript per `threadId`.

### Type-Safe Tool Call Events

When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. Prefer the AG-UI field `toolCallName` (or the deprecated `toolName` alias) β€” both narrow to the union of your tool name literals. The `input` field on `TOOL_CALL_END` is typed as the union of your tool input schemas (typically set on the adapter-emitted END once arguments are complete):
Expand Down
42 changes: 27 additions & 15 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
"label": "Streaming",
"to": "chat/streaming",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-17"
"updatedAt": "2026-07-29"
},
{
"label": "Connection Adapters",
Expand All @@ -186,7 +186,7 @@
"label": "Overview",
"to": "interrupts/overview",
"addedAt": "2026-07-16",
"updatedAt": "2026-07-22"
"updatedAt": "2026-07-29"
},
{
"label": "Tool Approval",
Expand Down Expand Up @@ -221,7 +221,7 @@
"label": "Overview",
"to": "resumable-streams/overview",
"addedAt": "2026-07-17",
"updatedAt": "2026-07-23"
"updatedAt": "2026-07-29"
},
{
"label": "Advanced",
Expand All @@ -242,31 +242,43 @@
"label": "Overview",
"to": "persistence/overview",
"addedAt": "2026-07-22",
"updatedAt": "2026-07-27"
"updatedAt": "2026-07-29"
},
{
"label": "Chat Persistence",
"to": "persistence/chat-persistence",
"addedAt": "2026-07-22",
"updatedAt": "2026-07-26"
"updatedAt": "2026-07-29"
},
{
"label": "Client Persistence",
"to": "persistence/client-persistence",
"addedAt": "2026-07-22",
"updatedAt": "2026-07-27"
"updatedAt": "2026-07-28"
},
{
"label": "Generation Persistence",
"to": "persistence/generation-persistence",
"addedAt": "2026-07-28",
"updatedAt": "2026-07-29"
},
{
"label": "Keep Generated Files",
"to": "persistence/keep-generated-files",
"addedAt": "2026-07-28",
"updatedAt": "2026-07-29"
},
{
"label": "Controls",
"to": "persistence/controls",
"addedAt": "2026-07-22",
"updatedAt": "2026-07-25"
"updatedAt": "2026-07-29"
},
{
"label": "Build Your Own Adapter",
"to": "persistence/build-your-own-adapter",
"addedAt": "2026-07-24",
"updatedAt": "2026-07-28"
"updatedAt": "2026-07-29"
},
{
"label": "Migrations",
Expand All @@ -278,7 +290,7 @@
"label": "Internals",
"to": "persistence/internals",
"addedAt": "2026-07-22",
"updatedAt": "2026-07-25"
"updatedAt": "2026-07-29"
}
]
},
Expand Down Expand Up @@ -377,7 +389,7 @@
"label": "Transcription",
"to": "media/transcription",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-03"
"updatedAt": "2026-07-28"
},
{
"label": "Audio Recording",
Expand All @@ -389,25 +401,25 @@
"label": "Audio Generation",
"to": "media/audio-generation",
"addedAt": "2026-04-23",
"updatedAt": "2026-06-08"
"updatedAt": "2026-07-28"
},
{
"label": "Image Generation",
"to": "media/image-generation",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-07"
"updatedAt": "2026-07-28"
},
{
"label": "Video Generation",
"to": "media/video-generation",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-02"
"updatedAt": "2026-07-28"
},
{
"label": "Generation Hooks",
"to": "media/generation-hooks",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-10"
"updatedAt": "2026-07-28"
}
]
},
Expand Down Expand Up @@ -618,7 +630,7 @@
"updatedAt": "2026-07-08"
},
{
"label": "Sampling β†’ modelOptions",
"label": "Sampling \u2192 modelOptions",
"to": "migration/sampling-options-to-model-options",
"addedAt": "2026-06-03"
}
Expand Down
18 changes: 18 additions & 0 deletions docs/interrupts/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ picks up exactly where it left off once you answer.
4. The client starts a fresh continuation run that carries your answers and
continues the agent.

```mermaid
sequenceDiagram
participant User
participant Client
participant Server

Client->>Server: send message β€” run starts
Server-->>Client: interrupt outcome β€” run ends without a final answer
Client->>User: pending decisions surface as `interrupts`
User->>Client: approve / reject / submit a value
Client->>Server: continuation request with the answers β€” a fresh run
Server-->>Client: the agent picks up where it paused, final answer
```

Note that the pause spans **two runs**: the interrupted one ends, and the
continuation is a new run. One user-visible turn, two run lifecycles β€” see
[Threads and runs](../chat/streaming#threads-and-runs).

No database is required. The browser sends the full message history back on the
continuation request, so a stateless server can rebuild the paused step and keep
going.
Expand Down
4 changes: 4 additions & 0 deletions docs/media/audio-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ flow. It mirrors the API of `useGenerateSpeech`, `useGenerateImage`, and
other media hooks β€” see [Generation Hooks](./generation-hooks) for the full
shape.

> **Note:** For long tracks, keep the run's status and result across a reload or
> a dropped connection β€” and the audio after the provider's URL expires β€” with
> [Generation Persistence](../persistence/generation-persistence).

### Server (streaming SSE route)

```typescript
Expand Down
5 changes: 5 additions & 0 deletions docs/media/generation-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ keywords:

TanStack AI provides framework hooks for every generation type: image, audio, speech, transcription, summarization, and video. Each hook connects to a server endpoint and manages loading, error, and result state for you.

> **Surviving reloads and dropped connections:** every generation hook takes the
> same `persistence` option `useChat` does, so a long run's status and result
> come back after a page reload or a dropped connection. See
> [Generation Persistence](../persistence/generation-persistence).

## Overview

Generation hooks share a consistent API across all media types:
Expand Down
3 changes: 3 additions & 0 deletions docs/media/image-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ try {

TanStack AI provides React hooks and server-side streaming helpers to build full-stack image generation with minimal boilerplate.

> **Note:** To keep a batch across reloads, or to keep the images after the
> provider's URLs expire, add [Generation Persistence](../persistence/generation-persistence).

### Streaming Mode (Server Route + Client Hook)

**Server** β€” Create an API route that wraps `generateImage` as a streaming response:
Expand Down
4 changes: 4 additions & 0 deletions docs/media/transcription.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ export async function POST(request: Request) {

TanStack AI provides React hooks and server-side streaming helpers to build full-stack audio transcription with minimal boilerplate.

> **Note:** Transcribing a big file can run long β€” keep its status and result
> across a reload or a dropped connection with
> [Generation Persistence](../persistence/generation-persistence).

### Streaming Mode (Server Route + Client Hook)

**Server** β€” Create an API route that wraps `generateTranscription` as a streaming response:
Expand Down
9 changes: 9 additions & 0 deletions docs/media/video-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ Currently supported:
- **Grok (xAI)**: grok-imagine-video (text-to-video + image-to-video) and grok-imagine-video-1.5 (image-to-video only) models
- **fal.ai**: MiniMax, Luma, Kling, Hunyuan, and other hosted video models

> **Video runs take minutes β€” don't lose them to a reload.** This is the
> strongest case for [Generation Persistence](../persistence/generation-persistence):
> it keeps a record of each run, so after a reload the hook shows that run's last
> known status and result instead of an empty form. A run that is still streaming
> against a durable server-side stream is re-attached and finished in place;
> otherwise the record is restored, not the provider work. And because provider video URLs expire,
> [keep the finished clip](../persistence/keep-generated-files) by saving its
> bytes to your own storage.

## Basic Usage

### Creating a Video Job
Expand Down
Loading
Loading