@tanstack/ai-claude-code
1.0.0 · 22 Oct 2026MIT13k / weekRepositorynpmAtom feed
34 stable releases, newest first, by release line. Notes are quoted as the maintainers published them.
- 1.x · latest line
1.0.0
22 Oct 2026- breaking
TanStack AI 1.0.0. This release removes every API that was deprecated in 0.x. Read the upgrade guide before you upgrade: tanstack.com/ai/latest/docs/migration/v1
Major Changes
- breaking
TanStack AI 1.0.0. This release removes every API that was deprecated in 0.x. Read the upgrade guide before you upgrade: tanstack.com/ai/latest/docs/migration/v1
Removed all
@deprecatedexports, options, fields, and type aliases. Each one has a replacement that already shipped in 0.x.Removed
addToolApprovalResponse()fromChatClientand every chat hook. Resolve the boundtool-approvalinterrupt withresolveInterruptinstead. The devtools eventtools:approval:respondedis also removed.Removed the old wire formats: the CUSTOM
approval-requestedandtool-input-availableevents, the[DONE]SSE line, thedatacopy offorwardedProps, pre-binding interrupt descriptors and theirapproval_requiredandclient_tool_inputreasons, and the STEP events that adapters sent next toREASONING_*events. Use a 1.0 client with a 1.0 server.Removed the
toolNamealias fromTOOL_CALL_STARTchunks. ReadtoolCallNameinstead.threadIdis now required onuseChatand every other chat hook,ChatClient, the generation hooks and clients,chat(),summarize(), the generation functions,codeModeWithSnippets(),selectRelevantSnippets(),CompactionContext, andSummarizeInput. TanStack AI no longer makes a thread id for you. Pass a stable id for each conversation.createChatHookfactory options no longer takethreadId: pass it on each hook call. Octane generation hooks takethreadIdinstead ofid.@tanstack/ai-sandboxremovesresolveDurableThreadIdandDurableThreadIdRequiredError.The published
@tanstack/ai-octane/uitypes are now strict.UI.Messages,UI.Message,UI.Part, andUI.Interruptstype their render children.ToolPropstypespart.inputandpart.outputfor the named tool.toolsComponentsandinterruptsComponentsare required when the chat options declare tools or named interrupts. Code that relied on the old loose types can fail to compile.A connection adapter's
connect()andsend()need the run context (withthreadId) as their fourth argument.ChatClientand the generation clients pass it.@tanstack/ai-sandbox:createToolBridgeCore(),startHostToolBridge(), aToolBridgeProvisioner, andexecuteHostTool()take a requiredthreadId. Bridged tools get it in their context.@tanstack/ai-sandbox: the harness tool bridge andexecuteHostTool()refuse a tool withneedsApproval. Before, the tool ran with no approval. RemoveneedsApproval, or run the tool with an HTTP adapter.stream()andrpcStream()pass the AG-UIRunAgentInputfor the run as the fourth factory argument, the same body the HTTP adapters send. Read it on the server withchatParamsFromRequestBody()orgenerationParamsFromBody(), sothreadId,runId, and interruptresumereach a server function.RunAgentInputis exported from@tanstack/ai/client.A generation
fetchergetsthreadIdandrunIdin its second argument, next tosignalandheaders. Pass them to the server generation call.generationParamsFromBody()andgenerationParamsFromRequest()read the generation input fromforwardedProps. A server that readsbody.datagetsundefined. Readbody.forwardedPropsinstead. They also requirethreadIdandrunIdand return them as strings, the same aschatParamsFromRequestBody()and the AG-UIRunAgentInput. A body that holds only the input is rejected.Removed the readers for data saved in old 0.x formats (old Cloudflare run-log records, a bare
UIMessagefrom client persistence, artifact records withoutblobKey).Removed provider models that are shut down.
@tanstack/ai-react-ui,@tanstack/ai-solid-ui, and@tanstack/ai-vue-uiget no 1.0 release. Import from@tanstack/ai-react/ui,@tanstack/ai-solid/ui, or@tanstack/ai-vue/ui.
- 0.9.x
0.9.1 – 0.9.22 releases that only update dependencies@tanstack/ai 0.71.0@tanstack/ai-sandbox 0.5.25Show each
0.9.0
13 Oct 2026Set reasoning with
chat({ reasoning }).Breaking: none. The adapter had no reasoning option before.
chat({ adapter: claudeCodeText('claude-opus-4-8'), messages, reasoning: 'xhigh', })The CLI-style adapters follow
chat({ toolChoice })where they can.
Minor Changes
Set reasoning with
chat({ reasoning }).A model with effort levels gets
--effort <level>.A budget model (Haiku) gets
MAX_THINKING_TOKENSwith the budget, or0foroff.The reasoning data is in
model-meta.ts, keyed by model id.ClaudeCodeModelReasoningByNameandCLAUDE_CODE_MODEL_REASONINGcome from it.
Breaking: none. The adapter had no reasoning option before.
chat({ adapter: claudeCodeText('claude-opus-4-8'), messages, reasoning: 'xhigh', })
Patch Changes
The CLI-style adapters follow
chat({ toolChoice })where they can.Claude Code.
'none'and a named tool turn off the built-in tools. The adapter then bridges none of your tools, or only the named one.'required'logs a warning.Codex, OpenCode, Grok Build, and ACP-compatible adapters.
'none'and a named tool limit only your bridged tools. The built-in tools of the agent stay on, so these values and'required'log a warning.
- 0.8.x
0.8.1
9 Oct 2026Only updates dependencies:@tanstack/ai 0.68.0@tanstack/ai-sandbox 0.5.22
0.8.0
9 Oct 2026usage.promptTokensis now the total input on the Anthropic, Bedrock Converse, and Claude Code adapters. It is the uncached tokens plus the cache reads plus the cache writes. Before, it was the uncached tokens only.totalTokensnow uses the newpromptTokens. The cache parts stay inpromptTokensDetails.cachedTokensandpromptTokensDetails.cacheWriteTokens. Other adapters already reportpromptTokensthis way.Breaking: if your code adds
cachedTokensorcacheWriteTokenstopromptTokensto get the total input, it now counts the cache two times. UsepromptTokensas the total. To get the uncached tokens, subtractcachedTokensandcacheWriteTokensfrompromptTokens.
Minor Changes
usage.promptTokensis now the total input on the Anthropic, Bedrock Converse, and Claude Code adapters. It is the uncached tokens plus the cache reads plus the cache writes. Before, it was the uncached tokens only.totalTokensnow uses the newpromptTokens. The cache parts stay inpromptTokensDetails.cachedTokensandpromptTokensDetails.cacheWriteTokens. Other adapters already reportpromptTokensthis way.Breaking: if your code adds
cachedTokensorcacheWriteTokenstopromptTokensto get the total input, it now counts the cache two times. UsepromptTokensas the total. To get the uncached tokens, subtractcachedTokensandcacheWriteTokensfrompromptTokens.
- 0.7.x
0.7.1 – 0.7.44 releases that only update dependencies@tanstack/ai 0.66.0@tanstack/ai-sandbox 0.5.20Show each
- 0.6.x
0.6.1 – 0.6.1210 releases that only update dependencies@tanstack/ai 0.61.0@tanstack/ai-sandbox 0.5.15Show each
- 0.6.1224 Sep 2026@tanstack/ai 0.61.0@tanstack/ai-sandbox 0.5.15
- 0.6.1024 Sep 2026@tanstack/ai-sandbox 0.5.13@tanstack/ai 0.59.0
- 0.6.921 Sep 2026@tanstack/ai 0.58.0@tanstack/ai-sandbox 0.5.12
- 0.6.821 Sep 2026@tanstack/ai 0.57.0@tanstack/ai-sandbox 0.5.11
- 0.6.617 Sep 2026@tanstack/ai 0.55.0@tanstack/ai-sandbox 0.5.9
- 0.6.510 Sep 2026@tanstack/ai 0.54.0@tanstack/ai-sandbox 0.5.7
- 0.6.43 Sep 2026@tanstack/ai 0.53.0@tanstack/ai-sandbox 0.5.6
- 0.6.328 Aug 2026@tanstack/ai 0.52.0@tanstack/ai-sandbox 0.5.4
- 0.6.227 Aug 2026@tanstack/ai 0.51.0@tanstack/ai-sandbox 0.5.3
- 0.6.127 Aug 2026@tanstack/ai 0.50.0@tanstack/ai-sandbox 0.5.2
0.6.0
25 Aug 2026Expose Claude Code setting sources and loaded skills in session metadata.
settingSourcesdefaults to['project']. Workspace projections (instructions, skills, MCP config) now load, and a local-process run no longer picks up the host's~/.claude. Pass['user', 'project', 'local']for CLI-equivalent behavior. Plugin projection installs at project scope.
- 0.5.x
0.5.0
24 Aug 2026Add headless BYOK:
defineByokin@tanstack/ai-client/byok, passbyokinto chat and generation hooks, and read keys on the relay withgetByokKeyfrom@tanstack/ai/byok/server. Provider ids are open slugs (x-byok-<slug>). Each adapter exports a{ id, label, env? }object (openaiByok, …);idis required.envis the env var name(s) for the relay — names only; the client never readsprocess.env. A wrong key surfaces as the provider's own401through the relay, so no client-side key check is needed. OpenRouter PKCE (@tanstack/ai-openrouter/pkce) saves the minted key underopenrouterByok.id.
Minor Changes
Add headless BYOK:
defineByokin@tanstack/ai-client/byok, passbyokinto chat and generation hooks, and read keys on the relay withgetByokKeyfrom@tanstack/ai/byok/server. Provider ids are open slugs (x-byok-<slug>). Each adapter exports a{ id, label, env? }object (openaiByok, …);idis required.envis the env var name(s) for the relay — names only; the client never readsprocess.env. A wrong key surfaces as the provider's own401through the relay, so no client-side key check is needed. OpenRouter PKCE (@tanstack/ai-openrouter/pkce) saves the minted key underopenrouterByok.id.
- 0.4.x
0.4.1 – 0.4.43 releases that only update dependencies@tanstack/ai 0.48.0@tanstack/ai-sandbox 0.5.0Show each
0.4.0
18 Aug 2026Harness adapters honor
chat({ outputSchema })on the same turn.Claude Code and Codex pass a native schema flag. OpenCode, Grok Build, and
acpCompatibleparse JSON from the final assistant text. The engine reads astructured-output.completeevent so harness prose is not parsed as JSON.
Minor Changes
Harness adapters honor
chat({ outputSchema })on the same turn.Claude Code and Codex pass a native schema flag. OpenCode, Grok Build, and
acpCompatibleparse JSON from the final assistant text. The engine reads astructured-output.completeevent so harness prose is not parsed as JSON.
- 0.3.x
0.3.2
12 Aug 2026make default Daytona + Grok/Codex sandbox runs work without extra wrappers
Headless Grok and Codex stay permissive when policy is deny-only plus default allow. Daytona remaps
/workspace, starts stopped sandboxes on resume, keeps secrets out of create-time envVars and command strings, and uses native fs/git plus session stdin. Nested skills project by name. Durable Grok journals when durability is wired.
Patch Changes
make default Daytona + Grok/Codex sandbox runs work without extra wrappers
Headless Grok and Codex stay permissive when policy is deny-only plus default allow. Daytona remaps
/workspace, starts stopped sandboxes on resume, keeps secrets out of create-time envVars and command strings, and uses native fs/git plus session stdin. Nested skills project by name. Durable Grok journals when durability is wired.
0.3.1
10 Aug 2026Only updates dependencies:@tanstack/ai 0.44.0@tanstack/ai-sandbox 0.3.1
0.3.0
4 Aug 2026- breaking
RunStatuswidened to include'aborted'(previously'running' | 'completed' | 'failed' | 'interrupted'). The union appears in a read position (get(): Promise<RunRecord | null>), so an exhaustiveswitchoverrecord.statuswith aneverdefault in your code stops compiling until it handles'aborted'. - breaking
Run types are re-exported from
@tanstack/airather than declared here, so therunsstore is typed against core'sRunStoredirectly.MemoryRunStoreimplements both optional list methods, and the shared conformance testkit covers them. - breaking
runPersistenceConformanceacceptsskipMethods. An optional method that is missing and not declared inskipMethodsnow throws instead of silently passing, so an existing backend running the suite may see a new failure telling it to implement the method or declare the omission.
Minor Changes
Fix
pipeToRunLogrecording an aborted drive as'completed'.pipeToRunLogchecked itssignalonly inside the per-chunk loop, so an abort that arrived between chunks — or a producer that reacted to the signal by simply ending its stream, which is whatchat()does — let the loop exit normally and fall through to the success path. The run was then recordedstatus: 'completed'with afinishedAtit never earned. The signal is now re-checked after the loop: an aborted drive finishes as'aborted'whatever the producer did on its way out. A producer that throws on abort still records'failed'(the thrown value is what a tailing client must be shown), a genuine completion still records'completed', anddurability.close()still runs on every exit path.The visible symptom was a false transcript on the worst possible run:
reapDetachedRunsforce-expiring a detached run past its TTL, destroying its sandbox, and reporting the run as having completed successfully. Any caller whose producer ends its stream on abort hit the same gap, including a takeover whose claim is lost mid-drive.With the status honest,
reapDetachedRunsno longer reports the TTL-expiry path as the'budget-exceeded'anomaly. That outcome is documented as meaning the journal read, translation, or log is misbehaving, and it is now reserved for the finalization path where the probe already said the agent was finished. On the expiry path there is no probe andrunBudgetMsis the only thing that stops a still-producing agent, so the designed stop reports'expired'— withstatus: 'aborted'distinguishing an agent cut off mid-sentence from one that had already finished.Add
@tanstack/ai-byteplus, an adapter package for BytePlus ModelArk: Seed chat models, Seedance video generation, Seedream image generation, and Seed Speech text-to-speech and transcription.Seedance was already reachable through
@tanstack/ai-fal, which proxies it. This package is the direct-to-BytePlus path — BytePlus billing and rate limits, the first-class Seedance request fields, and BytePlus's own model ids — so the overlap is deliberate. Seed Speech is a separate BytePlus product and needs its own API key (BYTEPLUS_VOICE_API_KEY), not the Ark key.Remove zod from
@tanstack/ai's dependency graph entirely.@ag-ui/coreis bumped to0.1.1-canary.beta.0, which drops zod from its runtime dependencies and declares it as an optional peer instead. Previously every@tanstack/aiinstall pulled zod in transitively through it.chatParamsFromRequest/chatParamsFromRequestBodywere the only zod consumers in this package — they validated request bodies with AG-UI'sRunAgentInputSchema. They now validate the sameRunAgentInputcontract structurally, so@tanstack/aiships with no schema-validation runtime at all and neither requires nor suggests zod.No API change: both helpers keep their signatures, still reject non-conforming bodies with a migration-pointing
AGUIError(chatParamsFromRequeststill throws a 400Response), and still carry TanStack's canonicalpartsfield through on messages. Validation errors now name the offending fieldmessages[1].content must be a stringinstead of a zod issue dump.zod remains fully supported for defining tools; it is simply no longer installed on your behalf. If you relied on getting zod transitively without declaring it, add it explicitly:
npm install zod.Adopt the AG-UI interrupt lifecycle for tool approvals, generic responses, and client-tool execution, with typed bound resolvers, atomic batches, and structured errors. Interrupts run ephemerally by resuming from the full client message history in a fresh child run — no persistence required.
This changes native approval and client-tool streams from legacy custom events to snapshot-plus-
RUN_FINISHEDinterrupt outcomes. DeprecatedpendingInterrupts,addToolApprovalResponse, rawresumeInterrupts, and legacy event readers remain as limited compatibility surfaces for migration;addToolResultremains supported.The artifact options for
withGenerationPersistenceare now namedArtifactPersistenceOptions.They were declared as a second
export interface WithPersistenceOptions, which TypeScript merged with the chat middleware's options of the same name. The merge was invisible but not harmless:withPersistence(chat, …)silently acceptedextractArtifacts/storageKey/allowInputUrl/artifactFetch, andWithGenerationPersistenceOptions— which extends it — advertisedsnapshotStreaming/snapshotIntervalMs. Every one of those is a no-op on the other middleware, so autocomplete offered options that did nothing.WithPersistenceOptionskeeps its meaning: the chat middleware's options.WithGenerationPersistenceOptionsis unchanged in shape and is still what you pass towithGenerationPersistence, so only code that named the artifact interface directly needs an edit:-import type { WithPersistenceOptions } from '@tanstack/ai-persistence' -function artifactOptions(): WithPersistenceOptions { +import type { ArtifactPersistenceOptions } from '@tanstack/ai-persistence' +function artifactOptions(): ArtifactPersistenceOptions { return { storageKey: ({ runId, artifactId }) => `media/${runId}/${artifactId}` } }A bootstrap shell that dies mid-setup now fails the run instead of exhausting the host's memory, and teardown's
destroyis no longer cancelled by the abort that triggered it.createBootstrapShell's sentinel loop had no exit but the sentinel.run()read lines until it saw<sentinel> <code>, and the stdout drainer resolved every parked waiter with''once the stream ended — indistinguishable from the empty linesshemits constantly. So when the shell exited without printing its sentinel (a missing binary, an OOM kill, the provider reaping the sandbox mid-bootstrap, a transport reset), the loop spun on an infinite supply of'', pushing each one into its output buffer until the host process died of memory exhaustion. Two independent terminators now exist:End-of-stream is signalled as
null, distinct from an empty line, sorun()rejects the moment the shell is gone — with the drainer's own thrown value attached ascausewhen the stream errored rather than ended.A per-command deadline for a shell that stays alive and simply never answers.
BootstrapShellOptions.commandTimeoutMsconfigures it; the default is 30 minutes, deliberately generous because setup steps legitimately run that long (npm install, image pulls).
drainStdoutalso unblocks every parked waiter in afinally, so a throw while iterating stdout can no longer leave callers on a promise nobody resolves.defineSandbox's teardowndestroyno longer forwardsctx.signal.destroyruns on every teardown path including the one caused by that signal aborting, so forwarding it handed the provider an already-aborted signal: a provider that honors it did nothing and returned successfully, and the instance-storedeletethat follows then removed the only pointer to a live, billed sandbox.SandboxInstanceStorehas nolist, so that sandbox was unreachable from then on. Teardown now uses a fresh controller with its own 60s bound — cleanup outlives whatever cancelled the work, without being able to hang forever. Same reasoning asclose()never being fenced by the run claim.Add browser-refresh durability to the
persistenceoption.The client
persistenceadapter now stores one combined record per chat id, the message transcript plus a resume snapshot, so a full page reload restores the conversation, rehydrates any pending interrupt, and rejoins a run that was still streaming (viajoinRun, when the connection is durability-backed). A bareUIMessagefrom an older store is still read for backward compatibility.If you hand-rolled a
persistenceadapter, update its write path.setItemnow receives the combined{ messages, resume? }record where it used to receive a bareUIMessage, so an adapter that assumed an array will write the new shape and then fail to parse it back — and because adapter reads are best-effort, the failure is silent: the conversation simply does not restore. Read{ messages, resume? }ingetItem(a bare array is still accepted), or switch to thelocalStoragePersistence/sessionStoragePersistence/indexedDBPersistenceadapters below, which handle it for you.The
persistenceoption also acceptstruefor a server-authoritative chat: the client caches nothing, and on mount it hydrates the thread from the server by itsthreadId(painting the stored transcript and tailing any run still generating). Use it to keep large transcripts off the client while the server stays authoritative for history; it needs a connection with ahydratehandler and a server GET endpoint (reconstructChat). Passing an adapter is client-authoritative; omittingpersistence(orfalse) is ephemeral, in-memory only.New web storage adapters are exported for this:
localStoragePersistence,sessionStoragePersistence, andindexedDBPersistence(plusStorageUnavailableErrorand theChatPersistedState/ChatStorageAdapter/ChatPersistenceOptiontypes). Because durability rides the existingpersistenceoption, every framework integration (react,solid,vue,svelte,angular,preact) gets it with no framework-specific code.A restored generation whose result can't be rebuilt now reports an error instead of repainting as a blank success.
Every
reconstructResultmapper ingeneration-reconstruct.ts(and the video client's built-inreconstructVideoResult) returnsnullwhen the persisted record lacks what it needs — most commonly an output artifact stored without a serveurl, which is possible becauseartifactUrlis optional server-side.repaintFromSnapshotsilently skippedsetResultin that case, leavingstatus: 'success'withresult: null: a state no consumer can render, and one that hides the real cause.When a mapper declines a snapshot whose status is
complete, the restore now settles onstatus: 'error'with an explanatory message and firesonError. A decline on any other status is still silent — arunningsnapshot has no result yet by definition, and the rejoin delivers it. A client with noreconstructResultmapper at all is unaffected.Server-driven generation hydration no longer swallows every failure.
GenerationClient/VideoGenerationClientmount hydration (persistence: true) wrapped the wholehydrateGenerationcall in a baretry { … } catch { return }, collapsing a transport error, a403from thereconstructGenerationauthorize gate, an unparseable body, and "no record for this thread" into one indistinguishable silent no-op — so an app could not tell a broken server from a fresh thread, and had no signal to retry.A genuine miss (the server reports no record) stays silent, as before.
A genuine failure now surfaces on
status/errorand firesonError, with a message naming the cause. A record the client's own validator rejects (unknown schema version, missing/invalidstatusorresumeState) counts as a failure, not a miss.The failure is skipped when a
generate()took ownership of the client while the hydrate request was in flight — the live run still wins.
Relatedly,
fetchServerSentEvents/fetchHttpStreamhydrateGenerationnow only treats a200carryingnullas a miss. Any other non-object body (a string, an array) rejects instead of being reported as an empty thread, so a misconfigured route no longer masquerades as a fresh one.A generation stream that ends without a terminal chunk now settles to
errorinstead of wedging the client ongeneratingforever.GenerationClient.processStream/VideoGenerationClient.processStreamonly settled the status onRUN_FINISHEDorRUN_ERROR. Afor awaitloop over a stream that simply ends — a proxy/load-balancer idle timeout, a server restart mid-run, or a durable log whose terminal append never landed — returns normally, so no catch fired and the client came to rest onstatus: 'generating',isLoading: false,result: null, withonErrornever called. Worse, the resume snapshot stayedrunning, so every subsequent mount rejoined the same dead run and repeated the same outcome.Both clients now throw when the stream ends with no terminal chunk seen (and the read wasn't aborted by
stop()/dispose()), which routes the failure through the existing error path:status: 'error',errorset,onErrorfired, and the resume snapshot rewritten to a terminalerrorwith a nullresumeStateso nothing chases it again. This applies to both the initialgenerate()path and the mount-timerejoinInFlightpath. A rejoin failure now also firesonError, matchinggenerate().This is the sibling of the earlier "rejoin settles to error" fix, which covered a missing and a throwing
joinRunbut not a join that returns cleanly with no terminal chunk.localStoragePersistence/sessionStoragePersistence/indexedDBPersistenceare no longer generic. Each returns aChatStorageAdapter<ChatPersistedState>, andWebStoragePersistenceOptionstypes itsserialize/deserializecodec overChatPersistedState.The type parameter existed so one adapter could back both the chat and the generation
persistenceoption. Generationpersistenceis nowboolean(server-driven only), so chat is the sole option that takes a storage adapter and the parameter had no second value to hold.A bare
localStoragePersistence()is unchanged. A call that passed an explicit type argument for a standalone store,localStoragePersistence<MyValue>(), no longer compiles: build that store with your own object literal, since these factories are for chat state.Extend the shared conformance testkit to the generation stores.
Migration — every existing adapter must update its conformance call. The suite now fails loudly on a store that is absent without being declared, so a chat-only adapter that used to pass unchanged will start failing on
generationRuns/artifacts/blobs. Declare them absent:- runPersistenceConformance('my-adapter', () => makePersistence()) + runPersistenceConformance('my-adapter', () => makePersistence(), { + skip: ['generationRuns', 'artifacts', 'blobs'], + })Drop an entry from
skipas you implement that store — the suite then holds it to the contract below. Declaring absence is deliberate: a silently skipped store is how an adapter ships agenerationRunsimplementation that was never exercised.runPersistenceConformancenow exercisesgenerationRuns,artifacts, andblobsalongside the four chat state stores, so a hand-rolled generation backend is held to the same gate as a chat one:createOrResumeidempotency andfindLatestForThread(latest bystartedAt, thread-scoped, terminal runs included) on the run store; upsertsave,list(runId)ordering, anddelete/deleteForRunscoping on the artifact store; and byte/metadata round-trips, overwrite, silent absent-keydelete, andlistprefix + cursor paging on the blob store. Two invariants that were easy to get wrong and are now checked:list'sprefixmatches literally and case-sensitively (a SQL backend usingLIKEfails on both counts, since SQLite'sLIKEis case-insensitive for ASCII and treats%/_as wildcards), and cursor paging visits every key exactly once.examples/ts-react-chat's self-containednode:sqliteadapter implements all seven stores and runs the full suite; its server-side generation route is backed by that adapter, so generated images survive a dev-server restart.Fix
@tanstack/aibreaking non-React TanStack Start builds.A JSDoc example on
replayRunStreaminlined a server-function builder chain. Comments survive intodist, and Start's server-fn Vite plugin decides whether a module needs compiling by regex-matching the source — so it treated this package as a server-fn module and tried to resolve the framework's@tanstack/*-startpackage, failing the build of any Solid/Vue/Svelte Start app (could not resolve "@tanstack/solid-start"). The example now declares the generator separately and no longer trips the match.GenerationMiddlewareContext.resultTransformsis now required.Middleware registers a result transform by pushing onto the array, so an optional one let a host that builds its own context omit it and silently no-op every registration — generation persistence would then mark a run completed with neither its result nor its artifacts written, with nothing to observe but the missing data. Every context the library builds already comes from
createGenerationContext, which always sets ``, so this only affects code that constructs aGenerationMiddlewareContextby hand: setresultTransforms:.summarize()accepts generation middleware, so summaries can be persisted.useSummarize({ persistence: true, threadId })type-checked exactly like the six media hooks, butsummarize()took nomiddleware, so no library path could ever write its run record and a reload restored nothing. It now takesmiddlewarelike thegenerate*activities: oneonStart, the result transforms applied to theSummarizationResult, thenonFinish/onError, in both streaming and non-streaming mode (a consumer that disconnects mid-summary firesonAbort). In streaming mode the transformed result is what is yielded, so the client and the persisted record hold the same object.GenerationActivitygained'summarize', andotelMiddlewaremaps it to thesummarizeoperation name. Summaries are text, so there are no artifacts: a persistence middleware stores the run record and its result and nothing else.Fix non-streaming
generateVideo()losing the generation when persistence is on.A non-streaming
generateVideo()call only SUBMITS a job — the video does not exist until a later poll — but it firedonFinishas soon as the job was queued and never applied the result transforms, and it never put the caller'sthreadIdon the middleware context. WithwithGenerationPersistencethat meantgenerateVideo({ threadId, middleware })threw for want of a scope, and (once given one) would have stamped the runcompletedwith no result, no url, and no stored bytes, while the eventual result had nowhere to land.Submitting a job now OPENS the run and
getVideoJobStatus()closes it, with the two calls correlated by the provider'sjobId— the one id a poller structurally cannot be missing, since it cannot poll without it. Nothing else has to be threaded through:generateVideo()(non-streaming) passesthreadIdand the prompt inputs to middleware, files the run under an id derived from the provider +jobId, applies the result transforms to the submission (so the run record captures thejobIdand stays resumable from a later request or process), and fires no terminal hook.getVideoJobStatus()acceptsthreadIdandmiddleware, and recomputes the same run id fromadapter+jobId. On the poll that first observes a terminal job state it resumes that run, applies the result transforms — which is where persistence copies the video into the blob store and rewritesurlto a durable one, so the returned result and the stored record carry the same urls — and firesonFinish, oronErrorwhen the job (or the url fetch) failed. Intermediate polls invoke nothing. Its result gainedjobId,expiresAt, andartifacts;VideoJobResultgainedartifacts(refs for persisted prompt INPUTS, e.g. a start frame).runIdon a non-streaminggenerateVideo()call is ignored (it remains the wire run id in stream mode). The run id has to be recomputable by the poll from thejobIdalone; honoring a custom one would reintroduce the failure this avoids — a caller who set it on the submit and forgot it on the poll would silently open a second record while the first sat unfinished forever.
Two consequences worth knowing. Because the job id only exists once the provider accepts the job,
onStartnow fires AFTER the submit request, so anotelMiddleware()span covers the run from acceptance onward rather than the submit round-trip, and a submission that FAILS (no job to key on) opens and immediately fails a run under the call'srequestId— terminal and unresumable, but filed under the thread so a hydrating client sees the failure. AndthreadIdmust reach the poll: omitting it makes generation persistence throw loudly rather than file the finished video where nothing can hydrate it.Add
defineLockto@tanstack/ai/locks: an identity typer for aLockStoreimplementation, matching thedefine*Storehelpers in@tanstack/ai-persistence. Pass awithLockobject and get autocomplete and contract checking inline, with no: LockStoreannotation, then hand it towithLocks.import { defineLock, withLocks } from '@tanstack/ai/locks' const locks = defineLock({ async withLock(key, fn) { const { release, signal } = await acquire(key) try { return await fn(signal) } finally { release() } }, }) const middleware = [withLocks(locks)]Add per-store typer helpers:
defineMessageStore,defineRunStore,defineInterruptStore,defineMetadataStore.Each takes a store implementation and returns it typed against the contract, so you get autocomplete and checking on the object literal inline — no separate
: MessageStorereturn annotation. They compose intodefineAIPersistence, which already infers exact presence: a store you define is a defined, non-optional, autocompleted key onpersistence.stores, and accessing a store you did not define is a compile error.import { defineAIPersistence, defineMessageStore, defineRunStore, } from '@tanstack/ai-persistence' const persistence = defineAIPersistence({ stores: { messages: defineMessageStore({ loadThread, saveThread }), runs: defineRunStore({ createOrResume, update, get, findActiveRun }), }, }) persistence.stores.runs // RunStore (defined) persistence.stores.interrupts // compile error — not providedDeprecate generation
idin favor ofthreadIdas the single identity.threadIdis the scope for the wire, devtools, and persistence. When it is supplied,idis typedneverso you cannot pass both. Legacyidremains only for ephemeral runs that have nothreadId(wire/devtools fallback) and is marked@deprecated.Fixed: a detached run's delivery log stays open, so a takeover can actually continue it.
The durable delivery sink behind
toServerSentEventsResponse/toHttpResponseappended a synthetic terminalRUN_ERROR("Request aborted") and calleddurability.close()on every abort. On a plain disconnect of a detachable run — the whole point of durable runs — that defeated the feature twice over:the log was terminalized, so a later attach's replay ended at the stored
RUN_ERRORinstead of continuing, andthat
RUN_ERRORis a chunk the takeover's journal replay cannot reproduce, soalignToStoredLogthrewJournalReplayDivergedError,pipeToRunLogrecorded the perfectly healthy detached run as'failed', and appended a second terminal error.
The sink now consults the run's own abort verdict.
withSandbox'sonAbortpublishes the newRunDetachedCapabilityon its detach branch — it is the only actor that has resolved both out-of-band cancel bands (AbortInfo.cancelRequestedandwasCancelRequestedon the record) plusdetachOnDisconnect— and core carries the fact to the transport on the stream object itself, so there is nothing for an application to wire.Only a plain, intentless disconnect of a detachable run is spared. An explicit cancel in either band, a disconnect on a non-detachable run,
detachOnDisconnect: false, a genuine provider failure, and a normal finish all terminalize and close exactly as before — a run is never left with an open log and no successor. Core additionally refuses to treat an abort carryingRUN_CANCEL_REASONas a detach whatever a middleware claims, so a user pressing Stop always gets a closed, terminal log.Surface server-side memory state in the TanStack AI DevTools.
The DevTools panel now has a Memory tab for any chat wired with
memoryMiddleware. It shows, per scope (session), an operations timeline (each turn's recall — query, fragment count, injected system-prompt size, whether memory tools were exposed, duration) and the current stored records/facts when the adapter implements the optionalinspect/listFactsmethods.Because memory runs on the server (whose event bus never reaches the browser), the middleware transports its state to the panel over the chat stream as a
memory:stateCUSTOMevent, which@tanstack/ai-client's devtools bridge re-emits as browsermemory:*events — the same pattern generation results use. The snapshot reflects memory as of the start of each turn; opening the panel mid-conversation replays the latest state so the tab isn't empty.@tanstack/ai-memory—memoryMiddlewareinjects amemory:stateCUSTOMchunk carrying recall metrics + aninspect/listFactssnapshot; exportsMEMORY_STATE_EVENTandMemoryStateEventValue.@tanstack/ai-event-client— adds thememory:snapshotdevtools event.@tanstack/ai-client— the chat devtools bridge re-emitsmemory:*from the transported chunk and replays the last snapshot ondevtools:request-state.@tanstack/ai-devtools-core— new Memory tab + per-scope memory store slice.
A sandboxed agent's run now survives a disconnect and can be picked back up by a later request instead of being torn down with the connection that started it. Wire
runs+durabilityintowithSandbox(the sameRunStorechat persistence uses) and a disconnect on a durable run leaves the agent running, recordsdetachedSince, and a later attach for the samerunIdreplays the stored log, aligns against it, and keeps streaming from where the previous host left off.Single-writer enforcement. A durable run is driven under a lease (
locks.withLock) plus anepochfence (RunRecord.driverEpoch, re-checked every 32 appends) plus a quiescence gate oversnapshot()before a successor starts appending. A superseded driver's append is refused (RunClaimLostError) and, separately, its refusal can no longer terminalize the run record it lost the claim to — only the current claim holder may write a terminal status.sandboxRunDriver(@tanstack/ai-sandbox) wires the claim and the run log together so an app suppliesrequest/runs/locks/durability/driverather than hand-rolling the claim/fence sequencing itself.Out-of-band cancel.
requestRunCancel(runs, runId)(durable — reaches a run being driven by a different host) and theRUN_CANCEL_REASONabort sentinel (in-process — fast path when the cancel reaches the driving host) are the only two channels that carry cancel intent; a plain disconnect and an explicit Stop produce the identical TCP close and are no longer conflated.wasCancelRequestedreads the durable flag back; a store failure degrades to a detach rather than throwing, since a scheduled TTL reaper (see thereapDetachedRunschangeset) can still reclaim a stuck detach — provided the application actually schedules it; nothing sweepsdetachedSinceon its own.@tanstack/ai-persistence'sonAbortnow distinguishes the two. An explicit cancel (either channel) or a non-detachable run writes terminal'aborted'. A plain disconnect on a detachable run writes nothing — the record stays'running'for a later attach to resume, rather than the previous behavior of marking every disconnect'interrupted'with a terminalfinishedAt.ai-codex,ai-claude-code,ai-grok-buildthread the durablerunIdthrough their journal and attach paths:resolveDurableRunIdenforces a caller-supplied id whenever sandbox durability is wired (throwingDurableRunIdRequiredErrorotherwise, since a random fallback id is not derivable by a successor),journalOptionsForbuilds the journal option only when durability is active, andalignedIfAttachingwraps the merged output stream so an attach replays and aligns against the stored log instead of restarting the agent.ai-opencodeandai-acpdo not journal; both route throughresolveDurableRunIdwith enforcement off (durable: false) and keep their generated-id fallback, purely so that whenever either gains journaling it inherits the caller-supplied-runIdrequirement instead of re-deriving it.makeFakeShellSpawnships from@tanstack/ai-sandbox/testkitfor exercising the journal/claim/driver seam against a fake shell without a real sandbox provider.RunErrorin@tanstack/ai-persistence's conformance suite now pins theundefined-vs-falsedistinction oncancelRequested,detachedSince,sandboxKey, anddriverEpoch: a fresh run must read all four back asundefined(not a coerced falsy default), and an explicitly-writtencancelRequested: falsemust round-trip asfalse, distinct from the unset case.
Breaking:
@tanstack/ai-sandbox'sRunDeps.durabilityis now a per-run factoryexport interface RunDeps { runs: RunStore - durability: StreamDurability + durability: (runId: string) => StreamDurability }A single
StreamDurabilityinstance is bound to one run (a backend adapter's offsets embed a cursor into one log), so holding one instance let a caller silently mis-bind a run at concurrency 1 (start({ runId })accepted an arbitrary id while the instance stayed bound to whatever run it was constructed for, writing the lifecycle record under one id and the events under another with no error) and let concurrent runs cross-talk (parallel runs interleaved into the same log, and whichever finished firstclose()d every other run's stream too).pipeToRunLogandRunControllernow resolve the log FROM therunIdbeing driven, once per run, which makes both failures unrepresentable.RunController.attachand the rest of its per-run surface now takerunIdexplicitly instead of assuming a single bound log.Not released — this stays a minor, not a major. The durability surface introduced in earlier phases of this branch has not shipped in a published version, so this break reaches no released consumer. Migration for anyone building against the unreleased surface: change
durabilityfrom an instance to(runId) => StreamDurability, and passrunIdtoRunController.attach.Breaking:
@tanstack/ai-persistence'sonAbortno longer marks every disconnect'interrupted'onAbortused to writestatus: 'interrupted'with a terminalfinishedAton every abort, including a plain disconnect.'interrupted'is not supposed to be terminal-shaped (isTerminalRunStatus('interrupted')isfalse), so stamping a terminal timestamp on it told every reader the run was over while it might only be paused or still streaming elsewhere.onAbortnow branches: an explicit cancel or a non-detachable run calls the newabortRunhelper (status: 'aborted', terminal); a plain disconnect on a detachable run writes nothing at all, leaving the record'running'for a later attach.Migration: a reader that treated every post-abort record as
'interrupted'with afinishedAtmust instead handle a'running'record with nofinishedAtas "detached, possibly resumable" rather than "over."withGenerationPersistence'sonAbortis unaffected — a generation job has no journal or agent loop to reattach to, so it still unconditionally finalizes as'aborted'.Not breaking, called out for completeness:
AbortInfo.cancelRequestednow populatesDeclared as a placeholder in an earlier phase and unpopulated; core now sets it from the abort reason (
truewhen the abort reason is theRUN_CANCEL_REASONsentinel,falseotherwise). Purely additive in the type sense — this widens what was alreadyboolean | undefinedtoward a real value — but is a behavior change worth flagging: middleware reading this field to distinguish an explicit cancel from any other abort now gets a real answer instead of alwaysundefined.fix(persistence): stop charging every adapter for sandbox-only run fields
runPersistenceConformancerequired every backend to round-trip four fields that only durable sandboxed runs use:sandboxKey,detachedSince,cancelRequestedanddriverEpoch, including the rule that an omitted patch key means "leave the column" while an explicitundefinedmeans "clear it". The case was deliberately non-skippable, so a Postgres adapter for a plain chat app failed conformance until it implemented four columns nothing in its stack would ever write.The assertions moved rather than disappeared.
runDurableRunFieldsConformancenow ships from@tanstack/ai-sandbox/testkit, beside the takeover and reaper suites that consume those fields, and takes the samerunsstore:import { runDurableRunFieldsConformance } from '@tanstack/ai-sandbox/testkit' import { persistence } from './persistence' runDurableRunFieldsConformance( 'my postgres runs', () => persistence.stores.runs, )So a chat-only backend leaves those columns out of its schema and passes, and an app that wires
withSandbox(sandbox, { runs, durability })proves them with one extra line. The fields were already optional onRunRecordandlistReclaimablewas already optional and feature-detected; the conformance suite was the only thing making them mandatory in practice.Docs follow the same split. The fields are explained where they are used, on
persistence/build-a-sandbox-adapter("The four run fields", with the failure each omission causes), andpersistence/store-referencemarks them sandbox-only and points there instead of teaching them inline. The chat walkthrough'srunsexample labels them SANDBOX ONLY, since a reader following it for a chat app should skip them.A sandboxed agent's output now survives the host that started it. The agent writes newline-delimited JSON to a journal file inside the sandbox instead of into a pipe the host holds, so the host can return, die, or be replaced without taking the agent down with it, and a bounded read of the already-stored event log lets a successor line its own output up against the prefix a previous host delivered.
This entry covers the journal, the journal reader, and the alignment primitive — the substrate the rest of the durable-run surface is built on. Detach, takeover, out-of-band cancel, and the reaping sweep ship in the same release and are described in their own entries:
RunRecord.cancelRequestedis written byrequestRunCancel(@tanstack/ai),detachedSinceandsandboxKeyare written bywithSandbox's detach branch (@tanstack/ai-sandbox), andsandboxRunDriver,reapDetachedRuns, andpruneJournalsall ship from@tanstack/ai-sandbox's root. What the pieces below give you on their own is: run an agent through a journal, read that journal back from byte 0, and replay a journal against a stored log without duplicating what is already there.@tanstack/ai:StreamDurability.snapshotStreamDurabilitygains a requiredsnapshot:snapshot: () => Promise<Array<{ offset: TOffset; chunk: StreamChunk }>>Everything stored for the run at the moment of the call, in append order, then resolve. It never tails and never waits for more entries, it resolves to `` for a run with nothing stored rather than throwing, and it returns a fresh array whose pair objects do not reach the stored log. The result carries no lock, so the last returned offset is not a permanent tail.
It exists because
readis the only read the interface had, andreadtails: it parks until the log is terminalized withclose()or the caller aborts. A crashed producer never callsclose(), so its log stays open forever andfor await (const entry of read('-1'))over it never finishes. A producer resuming that run could not inspect the log at all.snapshotis the read that returns.Breaking for a custom
StreamDurability. The interface is public and shipped in0.42.0, so an existing implementation stops compiling until it adds the method. The migration is one method: return your stored entries as{ offset, chunk }pairs in append order without waiting, and return `` for an unknown run.memoryStreamimplements it by peeking at its log map rather than creating one, so an unknown run resolves to `` and no empty never-completed log is left behind for the sweep to miss.@tanstack/ai-durable-stream: bounded snapshot over the existing protocoldurableStreamimplementssnapshotwith no protocol change. The control frame already carried anupToDatefield that the parser validated andreadthen ignored;readandsnapshotnow share one window-pulling loop whose only difference is whetherupToDate: trueends it. A livereadkeeps long-polling past it, asnapshotreturns there, which is what makes a snapshot bounded on a stream nobody ever closed.Two honest limits:
It is bounded by an internal ceiling of 1000 windows. A conforming backend reports
upToDatewithin one or two windows; a backend that keeps handing out advancing windows without ever reporting it gets aDurableStreamErrorrather than a read that never returns.It cannot return `` for a stream the backend never created. A snapshot must not create a stream as a side effect of reading, so an unknown stream surfaces whatever status the backend returns for it instead of an empty result.
memoryStreamis the implementation that satisfies the empty-run clause exactly.
@tanstack/ai-sandbox: the journalThe substance of the phase.
The journal. An agent's NDJSON stdout is redirected to
/tmp/tanstack-runs/<runId>.ndjsonwith its stderr in a<runId>.errsidecar and an{"__exit":N}sentinel appended when it exits. Because the host holds no handle on the agent's output, there is no pipe toSIGPIPE: a trigger can start the agent and return while the agent keeps writing.spawnNdjsontakes a newjournal?: { runId; dir?; attach?; pollIntervalMs? }.startJournaledAgent(handle, command, options)starts the agent and returns without waiting for it or reading its stdout. Stdin is still written directly to the process.readJournalNdjson(handle, options)reads a journal from byte 0 as parsed NDJSON, stops at the sentinel, and throws for a non-zero exit code so a calling adapter's existingcatchturns it into aRUN_ERROR, the same observable outcome the unjournaled path produces from a non-zerowait(). The sentinel is the exit code here; there is no process towait()on.DEFAULT_JOURNAL_DIR,EXIT_SENTINEL_KEY,journalPaths,journaledCommand,journalFollowCommand,journalReadCommand,journalExistsCommand, plusdecodeBase64StreamandtoJournalLinesfor byte-exact decoding and line splitting.
A
runIdmust be unique per run. The journal is append-only on purpose, because a takeover depends on the prefix a previous host delivered still being there, andDEFAULT_JOURNAL_DIRis a fixed absolute path that outlives any single sandbox, test, or process. A reusedrunIdtherefore does not start a fresh journal, it appends behind the previous run's sentinel, and a reader stops at the FIRST sentinel it sees: the new run appears to emit nothing, or to fail with the old run's exit code. This is deliberately not enforced, since refusing to append would break the append-only property the takeover relies on. Durability therefore requires a caller-suppliedrunId, and the harness adapters no longer paper over its absence:resolveDurableRunIdthrowsDurableRunIdRequiredErrorwhen sandbox durability is wired and norunIdwas passed, and only falls back to a generated id on a non-durable run. A random fallback is not recomputable by any successor, so no successor could derive the journal path.The reader.
readJournalandjournalReadStrategy(handle)pick between two strategies.followusestail -fand requires bothbackgroundProcessesandkillableProcesses; everything else falls back to a bounded poll, because a follower that cannot be stopped would leak an unstoppable process inside the sandbox. The follow path is streamed rather than buffered, and honors anAbortSignalitself instead of blocking onstdoutuntil a best-effort kill closes the pipe.Alignment.
alignToStoredLogreplays a journal from byte 0, reads the stored prefix once and eagerly throughsnapshot(), suppresses the chunks the log already holds, and forwards the remainder with plainappend. On a mismatch it throwsJournalReplayDivergedError(index, stored, replayed)rather than forwarding chunks whose prefix and suffix disagree about message identity. It appends and never upserts by design:memoryStream.upsertrejects an offset it did not mint, anddurableStreamhas noupsertat all because its offsets embed a backend-assigned cursor. Deriving the dedupe boundary from the log means there is no window in which a checkpoint and the log can disagree. Supporting pieces:createRunScopedIdGen(runId)(a counter with no clock and no randomness) andchunkFingerprint(every field except the wall-clocktimestamp).Journal lifetime. Reaching the sentinel means the run is terminal and the event log is now the run's record, so both journal files are deleted before
readJournalNdjsonfinishes. The ordering is load-bearing and asserted: the follower is stopped before its input is removed, and the stderr sidecar is read for the error message before the deletion that destroys it. A stream that ends without a sentinel deletes nothing, since the run may be mid-flight and a successor may still need every byte. This per-run cleanup covers only the runs a host watched to completion: a run that reaches its sentinel while detached has no reader, so nothing observes the sentinel.pruneJournals(see thereapDetachedRunsentry) is the sweep that bounds those, deleting a journal only once its run is provably terminal and keeping everything it cannot prove dead.Conformance.
runJournalConformanceandJournalConformanceConfig, reachable from@tanstack/ai-sandbox/testkit, so a provider can prove its journal behavior against the same suite the bundled providers run.Breaking:
SandboxCapabilities.killableProcesseskillableProcesses: boolean
New and required.
truewhen a spawned process can be terminated throughSpawnHandle.killand aborted mid-flight through thesignalpassed toSandboxProcess.spawn. A bring-your-own provider stops compiling until it declares one, which is the point: an omitted field would default to killable and leak an unstoppable follower into the sandbox. Migration is one line. Callers must branch on it before relying onkillor abort to reclaim a background process.Every bundled provider declares it. Cloudflare declares
false, because itskill()is a no-op and Workers RPC cannot serialize anAbortSignal, so atail -fstarted there can only be polled and abandoned.@tanstack/ai-sandbox-local-processand@tanstack/ai-sandbox-docker: UTF-8 decoding fixSeparate from the journal work and older than it. Both decoded spawn stdout and stderr with a per-chunk
Buffer.toString('utf8'), which corrupts any multi-byte UTF-8 character a Node stream happens to split across twodataevents: each half decodes independently into a replacement character. Both now use a streamingTextDecoderthat retains a partial trailing sequence across calls and flushes once at end of stream, so a genuinely truncated sequence still surfaces asU+FFFDinstead of being dropped. This is a correctness fix in its own right and applies to every consumer of these providers, journaled or not.@tanstack/ai-claude-code,@tanstack/ai-codex,@tanstack/ai-grok-build: deterministic ids on the journaled pathAll three now route agent stdout through the journal and mint message ids with
createRunScopedIdGen(runId)instead ofgenerateId(), so re-translating the same journal bytes produces the same chunk sequence.generateId()mixes inDate.now()andMath.random(), which makes "same bytes produce same chunks" false.Visible behavior change: message id format. Ids on the journaled path go from a provider-prefixed random id such as
grok-build-1785...-x7f2qto<runId>-0,<runId>-1, and so on. Anything that parses a provider prefix out of a message id, or assumes ids are globally unique across runs rather than unique within one, is affected.The determinism guarantee is translator-level, not stream-level. On codex and claude-code the adapter wraps the translator in
mergeChunkStreams(translated, channel.stream), splicing host-tool-bridge custom events from live tool execution into the middle of the stream. Those events do not occur on a replay at all, and even on the original run their interleaving position is timing-dependent rather than derivable from the journal. A run that used bridged tools can therefore still diverge post-merge. Nothing in this phase closes that.One run is now described by one record. Chat persistence and the sandbox run driver both read and write the same
RunRecord, so they can no longer disagree about the status of a givenrunId.RunStatus('running' | 'interrupted' | 'completed' | 'failed' | 'aborted'),TerminalRunStatus('completed' | 'failed' | 'aborted'),RunRecord,RunError,RunStore,isTerminalRunStatus,defineRunStore, andInMemoryRunStorenow live in@tanstack/ai(packages/ai/src/activities/chat/middleware/run-store.ts). ARunStoreneedscreateOrResume/update/get/findActiveRun;listByThreadandlistReclaimableare optional.RunRecord.erroris a structuredRunError({ message: string; code?: string }) instead of a barestring.RUN_ERRORchunks carry a providercode, and the Cloudflare event log already populated one, so a bare message forced consumers to string-match provider prose to decide whether to retry or escalate.isTerminalRunStatusis now a type predicate (status is TerminalRunStatus) over an exhaustiveness-checked map, so a caller inside the guard can pass the status where aTerminalRunStatusis required without a cast. Purely additive.defineRunStoreis now generic (<const T extends RunStore>(store: T): T), so an optional method the implementation actually provides stays known-present on the result instead of collapsing back to| undefinedon the interface. Purely additive.AbortInfogains an optionalcancelRequestedfield, and core populates it:packages/ai/src/activities/chat/index.tssets it from the abort reason viaisCancelRequestedReason(reason)—truefor theRUN_CANCEL_REASONsentinel,falsefor any other abort.stream-to-response.tsrelies on it to refuse treating an explicit cancel as a detach, so a user pressing Stop always gets a closed, terminal log. Middleware reading it to tell an explicit cancel from a plain disconnect gets a real answer.
StreamDurability: single-argumentappend, upsert as a separate capabilityStreamDurability.appendtakes exactly one argument:append: (chunks: Array<StreamChunk>) => Promise<Array<TOffset>>
Idempotent re-persistence of an already-stored range is a separate, optional method on a separate interface:
export interface UpsertableStreamDurability< TOffset extends string = string, > extends StreamDurability<TOffset> { upsert: ( entries: Array<{ chunk: StreamChunk; offset: TOffset }>, ) => Promise<Array<TOffset>> }Pairing every chunk with its offset structurally makes a length mismatch, a sparse hole, and an unpaired chunk unrepresentable.
memoryStreamreturnsUpsertableStreamDurabilityand validates the whole batch before mutating any stored state, rejecting a foreign-format offset, an offset minted for a different run, a duplicate within one batch, and a new offset claiming a position at or before the current tail.durableStreamin@tanstack/ai-durable-streamreturns a plainStreamDurabilityand deliberately does not implementupsert, because its offsets embed a backend-assigned cursor a caller cannot choose: a consumer that requires upsert now gets a compile error at the wiring site instead of a runtime throw, and the guard that used to raiseDurableStreamErrorfor caller-supplied offsets is gone.Breaking:
@tanstack/ai-persistence- breaking
RunStatuswidened to include'aborted'(previously'running' | 'completed' | 'failed' | 'interrupted'). The union appears in a read position (get(): Promise<RunRecord | null>), so an exhaustiveswitchoverrecord.statuswith aneverdefault in your code stops compiling until it handles'aborted'. - breaking
Run types are re-exported from
@tanstack/airather than declared here, so therunsstore is typed against core'sRunStoredirectly.MemoryRunStoreimplements both optional list methods, and the shared conformance testkit covers them. - breaking
runPersistenceConformanceacceptsskipMethods. An optional method that is missing and not declared inskipMethodsnow throws instead of silently passing, so an existing backend running the suite may see a new failure telling it to implement the method or declare the omission. - breaking
RunRecord.errorchanging fromstringtoRunErrorcosts no migration today: this package is still unreleased at0.0.0.
Breaking:
@tanstack/ai-sandboxThe package's own run-tracking types are gone in favor of the core ones:
- breaking
RunEventLog,InMemoryRunEventLog,RunEvent, andRunEventLogReadOptionsare removed. If you were reading sandbox run events for Cloudflare, the same event-log implementation now lives in@tanstack/ai-sandbox-cloudflare/agent. - breaking
RunErroris removed along with the package's localRunRecord,RunStatus,TerminalRunStatus, andisTerminalRunStatus. Import these from@tanstack/aiinstead. - breaking
pipeToRunLogandRunControllerno longer take an event log. They takeRunDeps: { runs: RunStore; durability: (runId: string) => StreamDurability<TOffset>; logger?: InternalLogger }—durabilityis a per-run factory, not a single instance (see the durable-agent-runs-takeover entry for why a single instance was unsafe). - breaking
RunController.attachtakes(runId, fromOffset, signal?): the run being attached, an opaquefromOffset: TOffset(stringby default) minted byStreamDurabilityinstead of a numericfromSeq, and an optional abort signal. - breaking
threadIdis now a required field wherever a run is created or looked up. - breaking
Terminal status names changed to match the shared
TerminalRunStatus:doneis nowcompleted,erroris nowfailed,abortedstaysaborted. The event log that moved to@tanstack/ai-sandbox-cloudflareconverged on the same vocabulary, with a live-data migration for records persisted under the old one (see below).
pipeToRunLogis now total: it never rejects.RunDeps.loggeris an optional sink for the failures the driver absorbs (a failingruns.update, a failingdurability.close(), a record that vanished before the terminal re-read), because a detached run has no caller left to receive an error. Every exit path still terminalizes, so a store or log failure no longer leaves a run wedged at'running'with live tailers parked on a log that never closes.Breaking:
@tanstack/ai-sandbox-cloudflareNew home of the run event log.
@tanstack/ai-sandbox-cloudflare/agentnow exportsInMemoryRunEventLogalongside the existingDurableObjectRunEventLog, plus theRunEventLog,RunEvent, andRunEventLogReadOptionstypes.The log now speaks core's run vocabulary rather than a legacy one of its own:
- breaking
Statuses are core's
'running' | 'completed' | 'failed' | 'aborted'(done→completed,error→failed), andisTerminalRunStatusis core's helper. ImportRunStatus/TerminalRunStatus/RunRecord/RunErrorfrom@tanstack/ai; the package no longer exports run vocabulary of its own. - breaking
The record is
RunLogRecord(exported from./agent): core'sRunRecord— requiredthreadId,startedAt/finishedAt, structuredRunError— plus the two fields only an event log needs, thelastSeqcursor and theupdatedAtactivity clock. - breaking
RunEventLog.openrequiresthreadId(and accepts an optionalstartedAt), matching core'sRunRecord. The interface also gainsupdate(aRunStore-shaped patch of the record's mutable fields; implementations must wake blocked readers, because a driver that terminalizes through itsRunStoreis ending the log with that call) andlist(every record the log holds). - breaking
The package no longer ships a run driver. Its
pipeToRunLog/RunControllercopy is deleted;SandboxCoordinatornow drives runs with core'sRunControllerfrom@tanstack/ai-sandbox, bound to the Durable Object log by two adapters exported from./agent:runLogStore(log)exposes the log as core'sRunStore, andrunLogStream(log, { runId })exposes one run of it as core'sStreamDurability— soalignToStoredLog,replayRunStream, and the rest of the portable durable-runs machinery compose with the DO log directly. The coordinator's WebSocket tail and?lastSeqwire protocol are unchanged. - breaking
Live-data migration. Records a Durable Object persisted under the old layout (
{ status: 'done' | 'error' | …; createdAt; updatedAt; threadId? }) are migrated in place, on first read, and written back so each record pays the conversion once:done→completed,error→failed,createdAt→startedAt, a terminal record gainsfinishedAt = updatedAt, and a record stored withoutthreadIdgetsthreadId = runId(the log runs no thread-scoped queries, so the self-reference cannot leak into thread history). Event rows (evt:) are raw chunks and are untouched.migrateStoredRunRecordis exported from./agentfor bring-your-own-backend logs that persisted the old layout. - breaking
Wire-visible.
GET /runs/:idand the coordinator's WebSocket terminalstatusframe now carry the converged status strings and field names. A client branching onrecord.status === 'done'must branch on'completed'(and'error'→'failed').
Durable streaming runs now survive a client disconnect (page reload) and can be tailed to completion by a rejoining client — no route-side detachment code required. Two internal fixes to
toServerSentEventsResponse/toHttpResponse, both additive with no public API change:- breaking
RUN_STARTEDis a durability flush boundary. One-shot generation activities (image, speech, transcription, summarize) emitRUN_STARTED, then await the provider for seconds, then a terminal. PreviouslyRUN_STARTEDsat in the batch buffer, so the durable log was empty for the whole run and a mount-timejoinRunfast-failed as "run gone". It now flushes immediately, so the run is resumable from the instant it starts. - breaking
The producer is decoupled from the HTTP response when durability is on. A client disconnect used to abort the producer and seal the log with
RUN_ERROR, even though the run kept running and recorded success. Now, on a durable (persistence-on) run, a response cancel detaches and the producer keeps draining into the log to its real terminal, so a rejoining client tails it to completion. This supersedes the earlier "producers terminalize the log on cancellation" behavior for durable runs only:No durability (persistence off) → unchanged: a disconnect aborts and stops the run.
Durability present (persistence on) → the run survives a disconnect.
A genuine caller stop — aborting an
abortControlleryou pass (e.g. wired torequest.signal, as the resumable-streams demo does) — still terminalizes the run, so opt-in die-on-disconnect keeps working.
durableStreamnow resolves the run id exactly the way core does, throughresolveResumeRunIdfrom@tanstack/ai: theX-Run-Idheader first, then the?runIdquery param. It previously read?runIdonly.Two consequences of the old query-only resolution are fixed:
- breaking
A
@tanstack/ai-clientPOST keeps its URL byte-identical to a plain chat request and carries the run id inX-Run-Id, so a POST producer route wired todurableStreamwrote to a random-UUID stream while the GET attach route addressed the real one — the producing and attaching routes never met. - breaking
A mid-stream reconnect re-POSTs with
Last-Event-IDand no?runId, which tripped the resume guard and threwresume offset requires a runId.
Behavior change: a request that names no run at all — neither header nor query — now throws instead of generating a run id. A generated id names a stream no attach request could ever address, so the producer appeared healthy while writing where nobody could read. This matches
DurableRunIdRequiredErrorin@tanstack/ai-sandbox. Pass the run id inX-Run-Id(what the client adapters send) or?runId.Make a reload rejoin fast, robust, and repeatable.
- breaking
memoryStreamfirst-chunk deadline now defaults to 100ms (was 30s). The common from-start join is a reload rejoining a run whose producer ran in a prior request: an in-flight run's log already holds chunks (it streams immediately, the deadline never applies), and an empty log means the run is gone — so failing fast lets the client re-enable input near-instantly instead of holding a dead connection open. RaisefirstChunkDeadlineMsfor a backend whose producer can legitimately start well after a joiner attaches. - breaking
ChatClientreload rejoin hardened: it bounds the wait for the first chunk and clears a dead resume pointer (so a stale pointer can't pin the UI in a loading state and can't be retried on the next load); it drops the hydrated in-flight partial only when real content arrives (never onRUN_STARTEDalone), so a rejoin that connects but delivers nothing can't leave an empty assistant bubble; and it no longer lets a replayedRUN_STARTED(which carries the provider run id) overwrite the persisted resume pointer with an id the durability log isn't keyed by — so a SECOND consecutive reload still re-attaches and continues.
Server-authoritative reconnect is now automatic and keyed on the thread, not the run.
A chat's durable identity is its thread; run ids are ephemeral (a single turn can span several runs via interrupts or tool continuations), so basing reconnect on a client-cached run id goes stale the moment a turn rolls to a new run. This moves the whole reconnect story onto the stable thread id, resolved by the server.
- breaking
RunStore.findActiveRun(threadId)— required store method returning the most recent'running'run for a thread. Implemented by the in-memory reference backend and covered by the conformance testkit, so any adapter that provides it is held to the same invariants (most-recent-running wins, thread-scoped, null when idle). - breaking
reconstructChatnow returns{ messages, activeRun, interrupts }(was a bare message array): the stored transcript as UI messages, a cursor to an in-flight run if one exists, and any pending human-in-the-loop interrupts (tool approvals / waits) plus the run they paused. It reads the active run before the transcript so observing "no active run" guarantees the transcript is final (closing a finish-window race). - breaking
@tanstack/ai-clienthydrates itself on mount. In server-authoritative mode (persistence: true) the client caches no transcript and no run pointer: on mountuseChat/ChatClientcalls the connection's newhydrate(threadId)(a JSON GET against the same endpoint), paints the returned transcript, and — if a run is in flight — tails it via the existingjoinRundurability replay. A reload and the same thread opened on another device are the identical, server-resolved path. No loader, noinitialMessages, noinitialResumeSnapshot, no app-side fetching required. - breaking
Interrupts reconstruct from the server too. A paused approval (a tool with
needsApproval) is restored fromreconstructChat'sinterruptsexactly as a persisted resume snapshot would be, so a reload — or another device — re-prompts the same approve/reject decision and resumes the run it paused. Previously the pending interrupt was only recoverable from client storage, so a fresh client showed the paused tool call with no way to resolve it.
Apps keep the single GET endpoint they already have (durability replay when a resume cursor is present, else
reconstructChat); everything else is handled by the hook.Fix
generateVideodropping result transforms and run identity, which made a persisted video restore as nothing.Streaming video was the only media activity that never called
applyGenerationResultTransforms, and never put the caller'sthreadId/runIdon the middleware context. BecausewithGenerationPersistenceregisters BOTH its artifact capture and its run-recordresultwrite as result transforms — pushed onto an optionalctx.resultTransforms— both silently no-opped. A completed video therefore stored a run record withstatus: 'complete'and nothing else: no result metadata, no artifact refs, no stored bytes, and no thread link (the run was filed under the internalrequestId). On reload the client found no output artifact and restored nothing.Streaming video now applies the transforms to its terminal result before yielding it, so the
generation:resultchunk and the stored run record carry the same URLs — including the durable app-origin URL thatartifactUrlstamps. It also passesthreadId,runId, andartifactInputsinto the middleware context, matchinggenerateImage.threadIdis now a documented option ongenerateVideo(it previously had none — callers passing one via an object spread type-checked but were silently ignored). When omitted, an id is still minted for theRUN_STARTED/RUN_FINISHEDwire chunks, but the middleware context getsundefinedrather than the minted value: a fabricated thread id is a slot no client can hydrate by, which is worse than recording no link at all.Fix generation mount hydration to run in the commit phase, and restore TTS results.
- breaking
The
GenerationClient/VideoGenerationClientused to kick off mount hydration from their constructor. Framework hooks build the client insideuseMemo, so that ran in React's render phase, and several clients mounting together re-fired the hydrate GET on every discarded/speculative render, flooding the connection pool (ERR_INSUFFICIENT_RESOURCES). Hydration now runs once frommountDevtools(the hooks' commit-phase mount effect), guarded byserverHydrationStarted. Note for direct (non-framework)GenerationClient/VideoGenerationClientusers: mount hydration and the "missinghydrateGenerationhandler" warning now fire frommountDevtools()rather than the constructor, so callmountDevtools()(as every framework hook does on mount) to trigger a server/storage restore;generate()still triggers it too. - breaking
New
reconstructSpeechResultmapper, wired into the speech hook of every framework package —useGenerateSpeech(React, Solid, Vue),createGenerateSpeech(Svelte) andinjectGenerateSpeech(Angular). A restoredTTSResultcarries no base64 bytes (they live in the blob store), so it surfaces the durable serve URL throughresult.artifacts; the speech clip now repaints after a reload instead of showing status only. Previously only React was wired, so a restored TTS run on the other four repaintedstatus/errorbut leftresultnull.
Generation persistence is server-driven only. The hooks'
persistenceoption is now a boolean.- useGenerateImage({ threadId, connection, persistence: localStoragePersistence() }) + useGenerateImage({ threadId, connection, persistence: true })A generation is one job with one result, not a growing transcript, so a browser copy of its record bought nothing that the server record does not already provide, and cost a second source of truth to keep in step. Worse, the two modes restored differently: a client snapshot can never hold the generated bytes, so
resultcame backnullfrom storage but whole from the server. One mode removes that split.Gone from
@tanstack/ai-client: theGenerationPersistencetype and the storage read/write path inGenerationClientandVideoGenerationClient.persistence: truestill requires a stablethreadIdat the type level, and still needs ahydrateGenerationhandler (every built-in connection has one) plus areconstructGenerationroute.The
initialResumeSnapshotoption went with it: it seeded the storage mode that no longer exists, so the server hydration handler is the only way a run is restored.None of this touches chat.
useChatkeeps both modes, andlocalStoragePersistence/sessionStoragePersistence/indexedDBPersistenceare still exported and still work for conversations.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).
withGenerationPersistencerecords each run in a dedicatedgenerationRuns(GenerationRunStore) store, keyed by the run's ownrunId(the same AG-UI run id the client sends), withthreadIdthe run's scope — it no longer overloads the chatRunStore. 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-memorygenerationRunsstore, anddefineGenerationRunStore/defineArtifactStore/defineBlobStoretype a custom store inline the waydefineMessageStore/defineRunStorealready do.Server-side load (
reconstructGeneration). A newreconstructGeneration(persistence, request, options?)server helper — the generation parallel ofreconstructChat— reads a?runId=(or?threadId=) from the request, authorizes it via anauthorizecallback, and returns{ resumeSnapshot, activeRun }JSON so a server-authoritative client restores the last run on mount. Requires thegenerationRunsstore.authorizeis 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 ablobs(BlobStore) store,withGenerationPersistencewrites each generated file's bytes to the blob store (keyartifacts/<runId>/<artifactId>), records anArtifactRecord, and attachesPersistedArtifactRefs to the result and the run record. A newartifactUrloption stamps a durable app-origin serve URL onto each ref (a newPersistedArtifactRef.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 viaextractArtifacts/nameArtifact;retrieveArtifact/retrieveBlob(which resolve the key throughresolveArtifactBlobKey) 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 withallowInputUrl(a predicate, so the check can't be skipped). Every artifact fetch is limited tohttp:/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.artifactFetchinjects thefetchused, for routing downloads through an egress-restricted proxy.memoryPersistence()ships in-memoryartifacts/blobsstores; the generation activities gainedthreadId/runIdoptions.@tanstack/ai-utilsaddsbase64ToUint8Array.Client (transparent restore). Generation hooks (
useGenerateImage,useGenerateVideo,useGenerateAudio,useGenerateSpeech,useGeneration,useSummarize,useTranscription, and their Solid/Vue/Svelte/Angular equivalents) take apersistenceoption, and it is boolean — server-driven only, with no client-storage adapter arm:truehydrates the last run for a stablethreadIdon mount, and the browser caches nothing. Restore is invisible: it repaints the normalresult/status/errorfields as if the run had just finished, and reports the in-flight run's id asrunId— there is noresumeSnapshot/resumeState/pendingArtifacts/resultArtifactshook 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'sjoinRundurability replay), exactly likeuseChat. With byte storage configured, a restoredresultis rebuilt whole, its media resolved to the durable serve URL and its refs onresult.artifacts; without it,status/errorrestore andresultstays null. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins ongenerate(...).threadIdis required wheneverpersistenceis set, 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 onthreadIdand nothing else; the legacyidis deprecated and typedneverwheneverthreadIdis supplied — pass one scope, not two. Previously the key fell back toidand 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 newstorageKeyoption maps each artifact to its blob-store key, so generated media can live in your own folder structure instead of the defaultartifacts/<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 newArtifactRecord.blobKey(it is no longer derivable once arbitrary) and reads resolve throughresolveArtifactBlobKey; records written before the field existed fall back to the default convention, so it is a non-breaking addition.findLatestForThreadis a required method onGenerationRunStore— a?threadId=lookup is the whole mount-time hydration path, so a store that cannot answer it cannot back generation persistence. TypeScript rejects a store that omits it; a JavaScript adapter that ships without it fails at the call, not silently.Snapshots arriving from the server are validated before anything is repainted, so a stale or malformed record cannot paint a bogus result.
A generation mount-time rejoin that can't finish now settles to
errorinstead of hanging ongenerating.- breaking
recordResumeSnapshotErrorsurfaceserroron the observablestatuseven when a streamedRUN_ERRORalready flipped the resume snapshot toerror(viaobserveResumeSnapshot). Previously its early-return skippedsetStatus, so a rejoin whose delivery log had aged out (or whose route couldn't serve the join) left the hook stuck ongeneratingforever. Guarded so the livegenerate()path doesn't double-emiterror. - breaking
GenerationClient/VideoGenerationClientdispose()no longer callsstop(): a teardown (unmount / React StrictMode dispose) must not mark the run non-resumable and wipe therunningsnapshot the way a user-drivenstop()intentionally does — that destroyed the resume state so a remount could never rejoin. It now aborts only the in-flight delivery, keeps the snapshot resumable, and re-arms mount hydration so a remount rejoins.
GenerationRunStatusnow uses the same vocabulary as chat'sRunStatus.- type GenerationRunStatus = 'running' | 'complete' | 'error' | 'interrupted' + type GenerationRunStatus = RunStatus // 'running' | 'completed' | 'failed' | 'interrupted'
The two enums described the same four lifecycle states under different names,
completeagainstcompletedanderroragainstfailed, for no reason either one could point at. An adapter storing both kinds of run had to keep two status vocabularies straight, and a sharedstatuscolumn needed two sets of checks. They are now one type, so one column and one check constraint cover both tables.If you wrote a
GenerationRunStoreagainst the old names, update the two literals your store maps or validates.runningandinterruptedare unchanged. The conformance suite round-trips both new literals — it writescompletedand thenfailedthroughupdateand reads each back throughget— so re-running it against your adapter will catch anything missed.The client-facing resume-snapshot status is unchanged (
idle | running | complete | error). It is a separate vocabulary with its ownidlestate, mapped from the store status byreconstructGeneration, exactly as chat mapsRunStatustoChatClientState. Nothing on the wire moves.Also corrected:
GenerationRunRecord.threadIdwas documented as an "optional link to the chat conversation that triggered this generation", and typed optional to match. It is the slot the run fills, the stable app-chosen keyfindLatestForThreadhydrates by, andwithGenerationPersistencerefuses to start a run without one — so the field is now required. A record written without a scope could never be found again, which is not a shape worth keeping representable.GenerationRunRecord.threadIdis now required.interface GenerationRunRecord { runId: string - threadId?: string + threadId: string … }GenerationRunStore.createOrResumerequires it on its input, and theresumeStatecursor on the hydration payload (ReconstructedGeneration,GenerationHydrationResult) narrows from{ threadId?: string; runId: string }to{ threadId: string; runId: string }.Why. The optional field described a record no code path could produce and no client would accept.
withGenerationPersistencealready refused to start a run without a scope, so every record the library writes has one.findLatestForThread— the only query that hydrates a generation — keys on it, so a record without one could be written and then never read back. And the client discarded any snapshot that arrived without one.That last disagreement was a silent failure: the server legitimately omitted
threadIdfor a record that had none, and the client's snapshot validation responded by dropping the entire snapshot (status, result and error along with the cursor), leaving a blank idle panel with no diagnostic while the provider kept billing. Making the field required removes the disagreement by construction rather than patching one side of it.Migration. If you wrote a
GenerationRunStore, make the column non-nullable and stop defaulting the field tonull/undefined. The conformance suite now assertsthreadIdround-trips exactly and is not mutated by an idempotentcreateOrResume, so re-running it against your adapter will catch anything missed. Records already stored without athreadIdwere unreachable byfindLatestForThread, so there is nothing to backfill for hydration to work delete them or assign them a scope.withGenerationPersistencereadsthreadIdfrom the activity instead of requiring it twice.- generateImage({ threadId, middleware: [withGenerationPersistence(p, { threadId })] }) + generateImage({ threadId, middleware: [withGenerationPersistence(p)] })The bug underneath (
@tanstack/ai). Four streaming activities spread the resolved wire identity over their own options:- (resolved) => runGenerateImage({ ...options, ...resolved }) + (resolved) => runGenerateImage({ ...options, runId: resolved.runId })streamGenerationResultmints a thread id for theRUN_*chunks when the caller passes none, so that spread overwrote the caller'sthreadIdwith an id known to nobody.generateImage,generateAudio,generateSpeech, andgenerateTranscriptionwere all affected;generateVideoalready did this correctly. Any middleware readingctx.threadIdon those four saw a fabricated value it could not tell apart from a real one, which is why persistence ignored the context and demanded the option.The option (
@tanstack/ai-persistence).WithGenerationPersistenceOptions.threadIdis now optional, and an override rather than the only source. The scope resolves toopts.threadId ?? ctx.threadId, and a run with neither throws a named error atonStartinstead of being filed somewhere nothing can hydrate it from. Code that passesthreadIdto both keeps working unchanged.The redundancy was also a trap: passing different values to the activity and the middleware silently split one slot in two, with the wire using one id and the record filed under the other.
InjectChatOptionsno longer exposesonResumeStateChange.injectChatsurfaces the run identity as therunIdsignal and pending interrupts throughinterrupts/pendingInterrupts/onInterruptStateChange, exactly like React / Solid / Vue / Svelte / Preact — but Angular's omit list was missing the key, soonResumeStateChangeleaked as a public option andinjectChatforwarded to it. Both the key and the forwarding are gone; a caller passing it now gets a type error instead of depending on an option no other framework offers.Breaking: the hooks expose
runIdinstead ofresumeState.- const { resumeState } = useChat({ threadId, connection }) - const liveRunId = resumeState?.runId ?? null + const { runId } = useChat({ threadId, connection })Every chat hook (
useChat/createChat/injectChat) and every generation hook (useGenerateImage,useGenerateVideo,useGenerateAudio,useGenerateSpeech,useGeneration,useSummarize,useTranscriptionand the Solid / Vue / Svelte / Angular equivalents) now returnsrunId: string | nullthe id of the run streaming right now, ornullwhen nothing is in flight.resumeStatewas a{ threadId, runId }pair whosethreadIdhalf was always the id the caller had just passed in, so the only new information it carried was the run id, wrapped in an object that had to be unwrapped and null-checked.runIdis the thing callers actually reach for: the handle you send to your own endpoint to cancel or poll a provider job, sincestop()only aborts the local stream and does not stop work already running on the provider.On chat it also reports more than
resumeStatedid.resumeStateonly ever held a run that was interrupted or being rejoined, so it stayednullthrough an ordinary streaming turn.runIdtracks every run: it is set when any run starts (including a rejoin) and cleared when it settles, backed by the newChatClient.getCurrentRunId().injectChat(Angular) exposed no equivalent field before and now returnsrunIdalongside the other frameworks.ChatResumeStateremains exported, sinceresumeInterruptsUnsafestill takes one. It is simply no longer part of a hook's return shape.New docs page: Id map covers what each id means on chat versus generation, how to choose a
threadId, and when to readrunId.Correct the
persistence/threadIdJSDoc on every generation hook.persistenceis nowboolean, but the tooltip still described a third value "a storage adapter: client-driven — the lightweight snapshot is cached undergeneration:<threadId>" — left over from the deleted client-side persistence surface, andthreadIdstill claimed persistence keys on it "in both modes". IDE tooltips on a public option were telling users to pass something the types reject. Both now describe the server-driven-only behaviour, andthreadIddocuments that the persisted record requires it (as doeswithGenerationPersistenceon the server).Make interrupt ownership explicit rather than assumed.
An AG-UI
Interruptis a shared envelope — a workflow engine's durable approval or another agent framework's pause can arrive on the same stream. What makes a pause resumable throughchat()is the binding this package attaches undertanstack:interruptBinding.- breaking
Interrupts that carry no binding this client understands now surface as
kind: 'unbound'withcanResolve: false, instead of being given a synthesized binding and rendered as resolvable generic interrupts. Resolving those produced an answer submitted against a run with nothing pending, which failed asunknown-interruptonly after the user had filled in the form. Unbound items never block submission of the interrupts that are yours. - breaking
The binding carries a wire version (
INTERRUPT_BINDING_VERSION). Readers reject a version they don't recognise rather than duck-typing its fields. A binding written before the field existed is still read. - breaking
INTERRUPT_BINDING_METADATA_KEY,withInterruptBinding()andreadInterruptBinding()are exported, so anything producing an interrupt this package must later resume attaches the binding through a supported API instead of copying the metadata key. - breaking
Interrupt classification is driven by the binding alone.
Interrupt.reasonis free-form AG-UI text another producer can also use, so it is now a display hint only and never decides ownership. - breaking
The interrupt protocol surface is enumerated instead of
export *. The unimplemented durable-recovery contract (InterruptRecoveryStateV1,InterruptRecoveryQuery, the never-calledloadInterruptStateadapter hook, and thepersistence-required/atomic-commit-unsupported/recovery-unavailableerror codes) is removed rather than published.
Interrupts: the application owns wire-schema validation, and the hashing dependency is gone.
The library no longer transforms a generic interrupt's wire JSON Schema into a validator or validates the resolved value against it, on either the client or the server. Whatever you pass to
resolveInterrupt(client) or send in theresumebatch (server) flows through as-is. Validate it yourself if you need to trust it, e.g. withz.fromJSONSchema(interrupt.responseSchema).safeParse(value)on the client and your own check on the server. Validation of a tool's code-authored Standard Schema (approvalSchema/inputSchema) is unchanged.This drops the
ajvandajv-formatsdependencies. Interrupt binding hashes and resolution fingerprints now use a small bundled SHA-256 instead of@noble/hashes, so that dependency is gone too. The wire hash shape (sha256:<hex>) is unchanged.localProcessSandboxno longer leaks a process per killed command on Windows.killTreerantaskkill /PID <sh> /T /Fand returned as soon asspawnSyncreported noerror— treating "I successfully asked" as "it died". Two things were wrong with that, and the second is the one that actually leaked.It never checked taskkill's exit status. A launched taskkill is not a successful taskkill. A genuine refusal (access denied, a protected process) was indistinguishable from a kill that worked.
taskkill /Tcannot reach the process anyway. Commands run through a git-bashsh, and MSYS's fork emulation runs the final command of a statement list — such as thetail -fbehind a journal follow read — under an intermediate shell that immediately exits. Windows never reparents, so the survivingtail.exepoints at a dead parent, andtaskkill /Twalks only live parent links. It misses the process and still exits0, which is why checking the exit status alone would not have caught this either. Measured on Windows 11: the journal conformance suite leaked 2 processes per run and the takeover suite 4, accumulating for the life of the machine. Streaming was unaffected (the reader honors its ownAbortSignalrather than waiting for the kill), so it failed silently and cumulatively and no test ever went red.killTreenow resolves the tree through MSYS's own process table — which does keep the logical parentage — before killing, since the taskkill destroys the only link back to our shell, then verifies each descendant is gone and kills the survivors directly. Both suites now leak 0. A process that had already exited on its own is recognized as success, not retried and not reported.Teardown remains total by construction: nothing here throws, because a throwing kill would strand a run mid-flight with its readers parked. That makes an unkillable process otherwise invisible, so
localProcessSandboxaccepts alogger:const dev = localProcessSandbox({ logger: { warn: (message, meta) => console.warn(message, meta) }, })Any object with a
warn(message, meta?)method satisfies the newLocalProcessLogger, including theInternalLoggeran adapter already holds.Nothing changes on POSIX, where
shreally is the command's parent and signalling the wrapper suffices.Move multi-instance locks to
@tanstack/aiunder a dedicated@tanstack/ai/lockssubpath, and nest persistence agent skills likeai-core.- breaking
LockStore/InMemoryLockStore/LocksCapability/getLocks/provideLocks/withLockslive in@tanstack/ai/locks(not the main@tanstack/aibarrel, and not@tanstack/ai-persistence). - breaking
@tanstack/ai-sandboxconsumes the coreLocksCapabilitytoken (no local lock re-export). - breaking
The locks agent skill moves with the code:
ai-core/locksin@tanstack/ai, notai-persistence/locks. - breaking
Agent skills under
@tanstack/ai-persistencenest asskills/ai-persistence/{stores,server,build-*-adapter}/. - breaking
locks guide under advanced middleware.
Rework tool-call fan-out budgets as middleware hooks (unreleased #965 API).
- breaking
Remove (never released):
maxToolCalls()strategy andchat({ maxToolCallsPerTurn }) - breaking
Add
onShouldContinuemiddleware hook so policies can stop further agent turns without aborting - breaking
Keep
AgentLoopState.toolCallCount/lastTurnToolCallCountfor strategies and middleware - breaking
Tool-call budgets are an app-owned middleware recipe (docs), not a built-in export
import { chat, maxIterations, type ChatMiddleware } from '@tanstack/ai' function toolCallBudget({ max, maxPerTurn, }: { max?: number maxPerTurn?: number }): ChatMiddleware { let perTurn = 0 return { onIteration: () => { perTurn = 0 }, onToolPhaseComplete: () => { perTurn = 0 }, onBeforeToolCall: () => { if (maxPerTurn == null) return if (++perTurn > maxPerTurn) { return { type: 'skip', result: { error: `Skipped: exceeded maxToolCallsPerTurn (${maxPerTurn})`, }, } } }, onShouldContinue: (_ctx, state) => max != null && state.toolCallCount >= max ? false : undefined, } } chat({ adapter, messages, tools, agentLoopStrategy: maxIterations(20), middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })], })Add server-side memory via a
recall/saveadapter contract in@tanstack/ai-memory.Memory is now a single, provider-agnostic contract with two verbs —
recallandsave— which is the shape every memory backend (in-process, Redis, and hosted vendors) naturally exposes.memoryMiddlewarerecalls relevant memory into the system prompt (and optionally injects vendor tools) before the model runs, then deferssaveof the finished turn viactx.deferso streaming is never blocked. Extraction, ranking, and rendering live inside each adapter — the middleware is thin.@tanstack/ai-memory(new package) — everything ships here:- breaking
Root:
memoryMiddleware, theMemoryAdaptercontract (recall/save/ optionalinspect/listFacts), and theMemoryScope/MemoryTurn/RecallResult/SaveReceipttypes. - breaking
@tanstack/ai-memory/in-memory→inMemory()— zero-dependency adapter for dev, tests, and single-process demos. Pass anembedderfor semantic scoring and/or anextractfunction to persist derived facts. - breaking
@tanstack/ai-memory/redis→redis({ redis, prefix? })— production adapter for plain Redis.iorediswires in directly;redis(node-redis v4+) via thefromNodeRedis(client)wrapper. Both are optional peer dependencies. - breaking
@tanstack/ai-memory/hindsight→hindsight(),@tanstack/ai-memory/mem0→mem0(),@tanstack/ai-memory/honcho→honcho()— hosted-vendor adapters. Their SDKs (@vectorize-io/hindsight-client,@honcho-ai/sdk) are optional peers loaded lazily; mem0 talks to its server over plain HTTP (no SDK). Vendors can expose LLM tools throughrecall(e.g. hindsight's retain/recall/reflect). - breaking
A shared
recall/savecontract-test suite (@tanstack/ai-memory/tests/contract) that any adapter — including third-party ones — can run.
- breaking
Removes the (unreleased)
@tanstack/ai/memorysubpath. The middleware, contract, and helpers all moved to@tanstack/ai-memory.
- breaking
The five
memory:*devtools events (memory:retrieve:started/:completed,memory:persist:started/:completed,memory:error) now carry recall/save payloads (adapter id, fragment/receipt counts,phase: 'recall' | 'save').
Align
MemoryScopeto the sharedScopetype (threadId).MemoryScopeis now an alias ofScopefrom@tanstack/aiso memory and persistence share one isolation vocabulary. The conversation key isthreadId(required); optional dims areuserId,tenantId, and reservednamespace. There is no publicsessionIdon memory scope — hard cut while@tanstack/ai-memoryis still0.x/ unreleased.- breaking
@tanstack/ai-memory—export type MemoryScope = Scope. Built-in adapters (inMemory,redis) and middleware usethreadId;sameScopealso matchestenantIdwhen present on the query. Redis index keys are now{prefix}:index:{tenantId|_}:{userId|_}:{threadId}(escaped). Hindsight banks use{user}__{threadId}. Anyone who wrote Redis rows under the pre-rename layout needs to reindex or wipe — keys are not dual-read. - breaking
@tanstack/ai-event-client—MemoryScopeLiteis{ threadId?, userId?, tenantId? }(devtools telemetry; not an isolation authority). - breaking
@tanstack/ai-client/@tanstack/ai-devtools-core— memory event payloads and the Memory panel registry follow the samethreadIdfield names.
Fix
memoryStreamtruncating a tool-calling (agent-loop) run at its first tool call.An agent-loop run emits one
RUN_STARTED/RUN_FINISHEDpair per iteration (finishReason: "tool_calls"for a turn that calls a tool, then"stop"for the final answer).memoryStreamtreated the first terminal chunk as the end of the log — both marking the log complete on append and ending the reader on read so a run that called a tool was delivered only up to that firstRUN_FINISHED: the tool result and everything after (the model's actual answer) never reached the client, leaving the tool call stuck "running" and the reply missing, on the initial stream and on any reconnect/reload.Completion is now driven solely by the producer calling
close()(which it does on every exit — the documentedStreamDurability.closecontract, honored bytoServerSentEventsResponse/resumeServerSentEventsResponseand detached producers). The reader tails across per-iteration terminals and ends when the producer closes, so a tool-calling run is delivered in full — live, on rejoin, and on a server-authoritative reload.A throwing middleware terminal hook no longer cancels every other middleware's teardown.
onFinish,onAbort, andonErrorwere fanned out in an unguardedforloop, so the first hook to throw skipped every middleware ordered after it. These are the hooks that release per-middleware resources —withSandbox'sonAbortdetaches or destroys the sandbox and stampsdetachedSince;withPersistence'sonAbortrecords the run's status through the store — so one transient store error leaked a sandbox permanently, for every middleware behind it in the chain.runOnAbortis additionally awaited fromchat()'sfinally, where a throw also replaced the original abort reason with the hook's error.All three fan-outs now give every middleware its turn: a throw is captured, logged on the
errorschannel (never invisible), and the loop continues. Instrumentation only reports the hooks that actually completed.Isolation does not mean silence, and the three hooks differ in who reports:
- breaking
onFinishreports. It is the only terminal fan-out on the success path, and it is wherewithPersistence'sonFinishsaves the assistant turn. The collected failures are therefore rethrown after the loop: a single failure as-is (so the store's own message,causeandcodereach the caller and the wire), several as anAggregateError. Previously they were swallowed, and a failedmessages.appendleft acompletedrun record for a turn that never reached storage with a middleware log line as the only trace anywhere.This fan-out is awaited after the run's
RUN_FINISHEDhas already been streamed, so the rethrow can only append to what the consumer saw, never retract it — and what it achieves differs per transport:Without durability, the throw escapes mid-response and the SSE / HTTP-stream encoder emits a trailing
RUN_ERRORcarrying the store's own message andcode.ai-clientsurfaces that as an error status, so the user is no longer told the turn was saved when it was not.With durability, the throw reaches the durability sink, which records it server-side and leaves the already-forwarded
RUN_FINISHEDstanding rather than appending a contradictory second terminal. That is intended: the save failed, not the run — the client did receive the complete stream. The improvement is that the sink observes the failure at all; before, it never did.
- breaking
onAbortandonErrorswallow, after logging. Both run once the outcome is already decided and already being reported — the abort reason, or the run's real error, whichchat()rethrows the moment the fan-out returns. A propagated hook throw there could only displace that outcome with a teardown artifact, so it stops at the log.
onChunkandonConfigare deliberately NOT guarded. They are transform hooks in the middle of the data path: swallowing a throw there would forward a chunk or a config the middleware had decided to reject, silently producing wrong output rather than a failed run. A throw from either still fails the stream, which is the correct behavior — so a middleware doing anything fallible insideonChunkoronConfigstill has to handle its own errors.feat(ai-client): only the chat on screen holds a stream —
attach()/detach()Behavior change for direct
ChatClientusers. Tailing no longer starts in the constructor. If you constructChatClientyourself, callclient.attach()when your view appears andclient.detach()when it goes. Every framework package in this repo does it for you, souseChat(React, Vue, Solid, Svelte, Preact) andinjectChat(Angular) users need no change.Why it had to move out of the constructor: a UI framework may build a client and then throw it away — React does on a double-invoked render. A discarded client is never mounted, so nothing ever calls
detach()ordispose()on it, and a connection its constructor had opened could never be closed. Traced with CDP: connection ids 1374/1396/1428/1437 were still held after eight thread switches, and a later request waited 210 SECONDS for a free slot (stallMs: 210752). No guard inside the client can fix that, because every guard runs on the instance the framework KEPT — the leak is in the instance it discarded. Only "idle until a view attaches" makes a thrown-away client harmless.A page can own many chats. Forty sandbox runs, or forty conversations, is a normal shape for this SDK. A browser allows only about six connections per origin, and each tailed run holds one for as long as the run lasts. So a handful of views is enough to consume every slot, and then every other request QUEUES: measured in the sandbox example, a fetch issued from the page took 93 seconds, while the exact same request from outside the browser took 17 milliseconds. The visible effects were a message vanishing from the transcript, no UI updates until a run finished, and a page reload that took 40 seconds.
Tailing used to begin only in the
ChatClientconstructor, so a view could never stop tailing and later resume — unmount had to either keep the connection open or lose the run for good. Keeping it open is what starved the page.ChatClientnow has a lifecycle pair:- breaking
attach()— start tailing (rejoin an in-flight run, hydrate if server-authoritative). Idempotent, so a wrapper's mount after construction is free. - breaking
detach()— drop the connection and keep everything else: transcript, resume pointer and run id all survive, so re-entering the view repaints instantly and re-tails from the durable log.
detach()is deliberately neitherstop()(which means the user ended the run) nordispose()(which means the client is finished). It says only that nobody is watching right now.Costs nothing for a chat that is not persisted. Both actions in
attach()are gated: the rejoin needs a persisted run pointer, and the hydration needs server-authoritative mode. An ephemeral chat issues no request when its view mounts or re-mounts.The React, Preact and Svelte wrappers now release the connection the moment their view unmounts. They previously deferred teardown through a timer that a re-mount could cancel — correct for disposal, useless for a connection. Svelte had no automatic cleanup at all and required the app to call
stop()by hand.Solid, Vue and Angular already dropped the connection immediately on unmount; they now also
attach()on mount, because the constructor no longer does. The generation and video hooks already revived on mount throughmountDevtools()and disposed immediately on unmount, so those are unchanged.Also fixed: a hydration request that resolved AFTER its view was disposed went on to open a tail on a dead client, which nothing could ever abort — one leaked connection per thread switch.
Drop
temperature/top_pfor OpenAI reasoning models so they don't 400.The o-series and the GPT-5 reasoning family reject
temperature/top_p(400 Unsupported parameter), but a caller — or the summarize adapter's low-temperature default — has no way to know a given model does. The OpenAI text adapter now strips both for reasoning models (matched byopenAIModelRejectsSamplingParams, which coverso*and non-*-chat-latestgpt-5*pluscodex-mini-latest). Stripping only ever averts a guaranteed 400, so it never changes an otherwise-valid request. This fixessummarize(and chat) ongpt-5.5and other reasoning models.Add server-side persistence for
chat(): durable thread messages, run records, and interrupts.withPersistence(persistence)is a chat middleware that stores the conversation transcript, tracks each run's status, and records interrupt state so a paused run (tool approval, client-tool execution, generic interrupt) survives a server restart.@tanstack/ai-persistenceships the contract, not a backend for your database:- breaking
The four store interfaces —
MessageStore,RunStore,InterruptStore,MetadataStore— with the invariants the middleware depends on (full-replacesaveThread, idempotentcreateOrResume, insert-if-absent interruptcreate,requestedAt-ascending listings). - breaking
The
withPersistence/withGenerationPersistencemiddleware, pluscomposePersistenceto assemble stores that live in different systems. - breaking
memoryPersistence(), an in-process reference backend for dev and tests. - breaking
LockStore/withLocks/InMemoryLockStorefor cross-worker coordination — deliberately not a state store, and not composable throughcomposePersistence. - breaking
A shared conformance testkit at
@tanstack/ai-persistence/testkit.runPersistenceConformanceexercises every method of every store you provide and fails loudly on a store that is missing without being declared inskip.
Implement the stores against whatever database you already run and hand the result to
withPersistence— the core never inspects your tables, so the schema stays yours. The Build Your Own Adapter guide walks through a completenode:sqlitebackend end to end, and the package ships Agent Skills with worked Drizzle, Prisma, and Cloudflare D1 recipes (npx @tanstack/intent@latest install).examples/ts-react-chatruns on a self-containednode:sqliteadapter built this way and verified by the conformance testkit.Resume reconstruction is delegated to the chat engine: persistence records interrupts and gates new input on a thread with pending interrupts, while the engine rebuilds the resume tool state from the resume batch and the interrupt bindings carried in the (server-loaded) message history.
reconstructChat(persistence, request)is a server helper that returns a thread's stored messages as a JSONResponse, so a server-authoritative client can hydrate its transcript on load from a one-lineGEThandler.Streamed artifact bodies can now be persisted to length-strict blob stores (Cloudflare R2),
maxArtifactBytescan be turned off, andBlobStore.getcan serve byte ranges.The bug. URL-fetched artifacts arrived at
BlobStore.putas aTransformStream-wrapped body — the wrapper that enforcesmaxArtifactBytesas the body drains. A transform's readable side carries no declared length, so runtimes that require one for a single-shot upload (workerd'sR2Bucket.put) rejected every URL-sourced artifact withTypeError: Provided readable stream must have a known length. Byte bodies never hit this, which is why the old conformance suite (byte bodies only) and any store that buffers were unaffected.The wrapper is now applied only when it is load-bearing. A trustworthy
content-lengthis checked against the cap up front, and HTTP framing holds the origin to it — a body cannot exceed a length it declared — so counting the bytes again adds nothing and costs the declared length. Those responses (the common case for a provider CDN) now reachBlobStore.putexactly asfetchproduced them, length intact, soR2Bucket.putsingle-shots them with nothing buffered. The counter still wraps the two response shapes that genuinely need it: a chunked reply (no declared length at all) and a content-encoded one (whose declared length measures the compressed bytes, so the decoded stream can be a decompression bomb).BlobPutOptions.expectedLength(additive).withGenerationPersistencenow forwards the artifact's exact decoded byte length toBlobStore.putwhen it is known — thecontent-lengthof an un-encoded artifact response. It is deliberately not forwarded when the response is content-encoded:fetchtransparently decompresses, so a gzipped reply'scontent-lengthis the compressed size and the decoded stream can be arbitrarily longer. Stores may use the hint to attach a declared length (e.g. workerd'sFixedLengthStream) and single-shot the stream, or fall back to multipart when it is absent. Also fixed in the same code: a missingcontent-lengthheader read as a declared length of0(Number(null) === 0), which kept the early-reject unreachable for chunked replies.BlobStore.get(key, { range })(additive). Serving a persisted video means answering HTTPRangerequests: seeking a<video>is built on206/Content-Range, and Safari refuses to play a source that ignoresRangeentirely.getnow takes aBlobGetOptionswith arange, returns just that slice, and reports it asBlobObject.rangewhilesizekeeps describing the whole object — the numbers a206needs.retrieveBlob(persistence, artifact, { range })passes it through, and two new helpers cover the fiddly halves:parseRangeHeader(header, size)resolves aRangeheader (suffix ranges, clamping, and the416case) for a serve route, andresolveBlobRange(size, range)does the clamping every byte-storing backend needs. Ranged reads are part of the contract for a store that holds bytes: thegetsignature stays source-compatible (the parameter is optional), so an existing custom store surfaces this as a conformance failure rather than a type error.maxArtifactBytes: false, and a 1 GiB default (was 100 MiB). The cap is a drain-time counter, not a buffer — the URL path streams into the blob store and never holds an artifact in memory — so it bounds transfer, not memory, and 100 MiB was simply too low for generated video. Passingfalsedrops the ceiling and the wrapper on every response, including chunked ones. Keep the cap whenallowInputUrllets callers name the URL, or when you want any ceiling at all on what a runaway origin can stream into your bucket (content-lengthis advisory, so uncapped is unbounded).Testkit.
runPersistenceConformancenow exercisesblobs.putwith a length-lessTransformStream-wrapped body — with and withoutexpectedLength— andblobs.getwith a byte range, so a store that only handles byte bodies or ignores ranges fails the suite instead of failing on first real use. Custom backends should re-run the suite.Skill fix.
ai-persistence/build-cloudflare-artifact-storeno longer claims the body "flows straight through" toR2Bucket.put: its R2BlobStore.putrecipe now re-declaresexpectedLengthviaFixedLengthStreamand falls back to a one-part-at-a-time multipart upload when the length is unknown, and itsgetmapsrangeonto R2's own ranged read.Add
reapDetachedRuns,pruneJournals, andreclaimSandbox: the sweep that closes out a detached durable run.Detaching a run on disconnect (see the durable-agent-runs changeset) leaves
detachedSinceon the record and a journal on disk, but nothing acted on either — recovery was manual. This ships the sweep, built onRunStore.listReclaimable:- breaking
reapDetachedRuns(options)— one argument. It makes exactly onelistReclaimable({ now, ttlMs: 0 })call and classifies expiry in-process against the same inclusive cutoff; listing twice with two TTLs would cost a second store round-trip to compute a subset of what the first call already returned. A run whose journal already reached its{"__exit":N}sentinel is driven to terminal so its transcript lands (outcome'finalized'); a run pastoptions.detachedRunTtlMsisrequestRunCancel'd and then driven (outcome'expired', with the run's ownstatusdistinguishing a replay that reached'completed'from an agent cut off mid-sentence at'aborted'). It never drives a run purely to find out whether it finished — the injected, requiredhasFinishedprobe reads the journal out of band first, andprobeRunExitis the shipped implementation.The remaining outcomes are not one bucket:
'producing'and'unknown'— left completely alone. The claim is never taken, so nothing appends, no terminal record is written,close()is not called, anddetachedSinceis untouched.'not-claimed'— also left alone. Another host holds the claim (a real viewer attaching mid-sweep is exactly this), or the run reached terminal in another host's hands between the listing and the claim.'budget-exceeded'— the opposite of left alone, and an anomaly worth investigating.pipeToRunLogis total, so the record is terminal, the log is closed, andreclaimfired. It is a diagnostic: a run the probe already said had finished failed to replay insiderunBudgetMs, which means the journal read, the translation, or the log is misbehaving. Finalization-path only — an expired run that outran its budget reports'expired', because there the budget firing is the designed stop.'reclaim-failed'— the transcript is saved and the record is terminal; onlyoptions.reclaimthrew, so the sandbox is still up. Not retryable by the sweep: a terminal record leaveslistReclaimablefor good, so this entry and itserrorare the only notice that the sandbox leaks. The shippedsandboxReclaimerrejects on its'destroy-failed'arm so this outcome is reachable without writing a customreclaim. It overwrites'budget-exceeded'when a run hit both — the leak is what needs acting on — andReapRunEntry.terminalizedAnywayis what keeps the budget anomaly on the entry: it is set if and only if that anomaly happened, independently of the outcome the reclaim later overwrote.'failed'— something threw; it is logged, recorded, and the sweep continues with the rest of the batch.
- breaking
pruneJournals(options)— one argument. It deletes a journal only once its run is provably terminal; every arm that is not a proven-safe deletion keeps, including a non-terminal'interrupted'run, an unknownrunId(a journal legitimately exists before its record does, so those are kept untilorphanTtlMs), a store lookup that threw, an undecodable filename, and an mtime age gate the sandbox'sfind/statcould not answer. - breaking
reclaimSandbox/sandboxReclaimerreclaim the sandbox recorded against a run once it's terminal, fromRunRecord.sandboxKey.reclaimSandboxreturns'destroy-failed'— not'destroyed'— when the provider'sdestroythrows. The instance record is deleted either way (a record pointing at nothing guarantees a failedresumeon the thread's next turn), so an operator has to be able to tell "torn down" from "possibly still billing and no longer reachable from here";sandboxReclaimerlogs that one arm above debug level and rejects with aSandboxReclaimFailedError(also exported), becauseReapOptions.reclaimis(record) => Promise<void>and a rejection is the sweep's only channel for "the sandbox was NOT reclaimed" — logging and resolving reported the run'finalized'and leftoutcomes['reclaim-failed']reading0on exactly the leak it watches for. Every other outcome resolves: they all mean there was nothing for the reclaimer to tear down. A mismatched provider touches nothing at all, record included. - breaking
All of the above, plus the journal listing and exit-probe helpers behind them, are exported from
@tanstack/ai-sandbox's root.
ReapOptions.detachedRunTtlMsis the ONLY detached-run TTL.withSandbox'sdurabilityoption deliberately has nodetachedRunTtl, and the capability payload (SandboxRunDurability) carries nodetachedRunTtlMs. An earlier iteration of the unreleased durability seam accepted a duration string there, validated it at setup, and published the parsed value on the capability bus — where nothing read it. It could not be read: the only actor that enforces a TTL isreapDetachedRuns, which runs from a cron with no chat in flight, so it has noCapabilityContextand cannot reach that bus at all. Two knobs in two units at two call sites bought only silent divergence —detachedRunTtl: '30m'onwithSandboxnext todetachedRunTtlMs: 5 * 60_000on the sweep expired runs at five minutes while the configuration claimed thirty, and nothing warned. Configure the TTL once, in milliseconds, where it is actually enforced. Nothing is lost on the fail-loud front either: the reaper's option is a requirednumber, so a'30min'-style typo is now a compile error rather than a runtime one. Neither the option norSandboxDurabilityOptionsitself has ever appeared in a published release, so no released API changes.This ships a function, not a scheduler.
reapDetachedRunsdoes not run itself — the application must call it on its own cadence (a cron job, a Vercel Cron route, a Cloudflare Workersalarm(), a queue consumer). An app that wiresdurabilityandrunsbut never schedules the reaper has nothing closing out detached runs: tailers on a detached run park forever,detachedRunTtlMsis enforced by nothing, and a sandbox that should have been reclaimed keeps billing. Wiring durability and scheduling the reaper are two separate integration steps; only the second one closes the loop.This corrects
durable-agent-runs-takeover.md's claim that "the TTL reaper still reclaims a stuck detach" — that is only true once the application schedulesreapDetachedRuns. Nothing reclaimed anything automatically before this changeset, and nothing does after it either, absent that wiring.Fix
useChataborting an in-flight delivery resume on mount. Whenlivewas not enabled, the mount effect calledclient.unsubscribe()unconditionally, which cancelled the shared in-flight stream — including thejoinRunrejoin the client had just started for a reloaded run. The result was a mid-stream reload that caught up to the buffered point and then froze instead of continuing.useChatnow only tears down a subscription it actually started, so a reload rejoins and streams the run through to completion.Resumable streams: reconnect to an in-flight SSE or NDJSON response without re-running the provider.
toServerSentEventsResponseandtoHttpResponseboth accept adurability: { adapter, batch }option. The adapter (StreamDurability) records every chunk to an ordered log before delivery and tags each event with an opaque, adapter-owned offset — an SSEid:line, or theidof an NDJSON{ id, chunk }envelope (NDJSON has no native event-id). A reconnect (Last-Event-ID) or an explicit?offsetread replays strictly after that offset from the log — the lazy provider stream is never iterated on resume. Producers terminalize the log on cancellation and failure (RUN_ERRORappend- breaking
close()) and on completion when the source stream emits its own terminal event (chat()always does), so readers are never parked on a dead run.
Two adapters ship:
memoryStream(request)in@tanstack/ai(process-local, for development and tests) and the new@tanstack/ai-durable-streampackage, a Durable Streams protocol adapter for production backends.For the
GEThandler that a reload or a second tab reconnects to,resumeServerSentEventsResponse({ adapter })andresumeHttpResponse({ adapter })replay a run straight from the durability log. They need no producer stream and return a 400 when the request carries no resume offset.On the client, all four HTTP adapters are now resumable —
fetchServerSentEvents,fetchHttpStream,xhrServerSentEvents, andxhrHttpStream. Each tracks the per-event offset, auto-reconnects withLast-Event-ID, de-duplicates the replayed prefix, and exposesjoinRun(runId)to attach to an in-flight or finished run from the start (read-only GET withoffset=-1). Untagged streams behave exactly as before. A durable run that ends with no terminal event and no forward progress now throwsDurableStreamIncompleteErrorinstead of hanging.Reconnection and durability are bounded so failures surface rather than hang or loop:
- breaking
memoryStreamevicts completed logs after a grace window (unbounded growth is gone); resuming an expired/unknown run throws, and a from-start join to a run that never produces fails afterMemoryStreamOptions.firstChunkDeadlineMs. - breaking
all four HTTP adapters accept
reconnect: { maxAttempts, delayMs }— a throttle plus a ceiling on CONSECUTIVE no-progress reconnects (default 5; forward progress resets it) that fails with the newStreamReconnectLimitErrorinstead of reconnecting endlessly, without penalizing a healthy long-lived run. - breaking
durableStreamacceptsreconnect: { maxReadFailures, delayMs }to bound its read-retry loop, andserveris now optional whenfetchis provided (e.g. a Cloudflare service binding). - breaking
toServerSentEventsResponseacceptsdebugto record durability terminal / close failures server-side, where a replaying joiner cannot observe them.
A durable run is now joinable from the moment it is accepted, not from its first real chunk — closing the window where refreshing during a sandbox boot permanently orphaned a live run.
- breaking
@tanstack/ai: a fresh durable producer appends (and forwards) a syntheticCUSTOMchunk namedRUN_ACCEPTED_EVENT('run.accepted', exported) to the delivery log before the producer stream is first pulled. Pulling the stream is what runs the middleware chain, and middleware that boots a sandbox (create a container, install a CLI) can legitimately emit nothing for minutes; during that window the log was empty, so every joiner's empty-log fail-fast (memoryStream's first-chunk deadline, the client's rejoin connect deadline) read the run as gone. Takeover alignment is unaffected: a journal replay cannot reproduce the marker, and alignment already skips storedCUSTOMchunks as out-of-band. Consumers that assert exact chunk sequences over a durable wire will see the marker first. - breaking
@tanstack/ai-client: a rejoin that times out before attaching now KEEPS the persisted resume pointer (the run may simply not have produced yet); only a join the server refuses with a hard error (unknown / evicted run) clears it. Previously one connect-deadline timeout cleared the pointer, so the next reload never retried.
isTerminalRunStatusno longer reports a prototype-chain property as terminal, andisRunStatusships so a backend can validate a stored row.isTerminalRunStatustestedstatus in TERMINAL, andinwalks the prototype chain — so a store row whosestatuscolumn held'toString','constructor', or'valueOf'was reported terminal.statusis typedRunStatus, but every value reaching the guard comes off a user-implementedRunStore(JSON out of D1, a Durable Object, Postgres) where nothing validated it, so the type was only a claim. A falsetrueis destructive, not cosmetic:@tanstack/ai-sandbox's journal sweep deletes a terminal run's journal — the only copy of the bytes a successor needs, with no undo —attach-preflightfails the attach as'terminal-run', and core's resume driver refuses to drive the run.- breaking
isTerminalRunStatusnow usesObject.hasOwn(TERMINAL, status), keeping theRecord<TerminalRunStatus, true>exhaustiveness trick that makes adding a terminal status a compile error. - breaking
New:
isRunStatus(value: unknown): value is RunStatus, exported from@tanstack/ai, over the same exhaustiveness trick across the full union. Run it on a row'sstatusat deserialization in yourRunStore— the readers downstream act destructively on the answer. Core now does this at its own only store-status read (the resume driver), which refuses an unrecognized status and logs on theerrorschannel instead of coercing it. - breaking
DetachableRunCapabilityandRunDetachedCapabilityarecreateCapability<true>()rather than<boolean>. Absence is the documented negative for both, so a publishedfalsehad no meaning yet was representable — and a consumer testing presence rather than the value would have read one as the positive. Narrowing the payload makes that unrepresentable. A provider that publishedfalse(which meant nothing) stops compiling; publish nothing instead.
RunStore.findActiveRunis now required. It was optional and feature-detected (store.findActiveRun?.(threadId)), which meant an adapter that had not implemented it was indistinguishable from one reporting "nothing is running":reconstructChatreturnedactiveRun: null, and a client reloading mid-generation silently never reconnected to the run still producing. That is a production failure the type system was in a position to catch.Adapters that already implement
findActiveRunneed no change. Adapters that do not will now get a compile error; implement it as "most recent'running'run for the thread,nullif none" — in SQL,WHERE thread_id = ? AND status = 'running' ORDER BY started_at DESC LIMIT 1. A backend that genuinely has no run lifecycle should declareChatTranscriptStoresand omitrunsentirely rather than stub the method.The store-contract evolution policy changes to match: new store methods are added as required, and capability tiers are expressed at the store level, not by optional methods. The conformance testkit no longer skips its
findActiveRunassertions when the method is absent, andfindActiveRunis no longer accepted inskipMethods.RunStoreitself now lives in@tanstack/ai(isTerminalRunStatus/defineRunStore/InMemoryRunStoretravel with it and are re-exported from@tanstack/ai-persistence), so the requirement applies there too.@tanstack/ai-sandbox'sfenceRunStoreforwardsfindActiveRununconditionally rather than materializing it only when the wrapped store has it;listByThreadandlistReclaimableare still forwarded only when present.fix(sandbox): a durable sandboxed run survives losing its viewer instead of being killed by it
A client disconnect could only reach
withSandboxif the application mirroredrequest.signalintochat()'sabortController— which aborts the run.chat()then returned at its cancellation check immediately after middlewaresetup, so the harness adapter'schatStreamwas never called and the agent in the sandbox thatsetuphad just spent minutes building was never launched. Switching away while the UI still said "starting the sandbox" left an empty log for a run that did nothing — unrecoverable even by takeover, because an agent that never started writes no journal to replay. Applications had to choose between "the middleware learns about the disconnect" and "the run survives it".A disconnect is now delivered as a notification rather than a cancellation: the durable transport tells the run its response body was cancelled, without aborting it.
withSandboxrecordsdetachedSince/sandboxKeyand publishes the detach verdict, and the run keeps draining into its still-open delivery log for a rejoining client to tail. Teardown stays where it belongs — the file watcher is not stopped and the sandbox is not destroyed while the run is still using them.A genuine stop is unchanged: it arrives out of band (
RunRecord.cancelRequested/RUN_CANCEL_REASON), and aborting anabortControlleryou pass still terminalizes the run.A third thing was missing in that window, and core now owns it: the delivery log was empty, so every joiner's fast-fail (
memoryStream's first-chunk deadline, the client's 2s rejoin connect deadline) read a live run as gone.RUN_ACCEPTED_EVENTcovers it for every durable run, sowithSandboxdeliberately appends no marker of its own — a second one would only land mid-stream in a run already producing.Two related fixes, both found while verifying the above:
- breaking
withSandboxregistered its run state only afterdefinition.ensure(), so a disconnect during the minutes-wide sandbox creation — the likeliest moment for one — was a silent no-op. State is now registered beforeensure. - breaking
A durable run was invisible for the whole of
ensure. Chat persistence creates the run record fromonConfig, which runs after every middlewaresetup, so during the minutes a sandbox takes to build there was no record at all:findActiveRunreported nothing running for a run that was demonstrably starting (measured: a status sidebar showedidlefor 6.5 minutes), a client returning to the thread had nothing to tell it a run was in flight and rendered an empty pane, a crash in that window left nothing forlistReclaimableto surface, and the detach stamp silently no-opped becauseRunStore.updatedoes nothing for an unknown runId.withSandbox.setupnow pre-creates the record beforeensurefor durable runs only;createOrResumeis idempotent, so persistence's own later call simply finds it.
withSandboxalso records the harness's own tool calls into the transcript, so a FINISHED run restores its tool cards instead of only its verdict. A harness executes its tools inside the sandbox, sochat()merely relays theirTOOL_CALL_*chunks it never wrote an assistant message for them, and chat persistence storesctx.messages. The tool history therefore existed only in the run's delivery log: switching away and coming back replayed all of it (there was a live run to rejoin), while a reload after the run finished hydrated from the message store and got the prompt plus the final text and nothing else (measured: 487,443 characters of transcript before the reload, 4,014 after). They are recorded as ordinarytoolCallsplusrole: 'tool'messages, which needs no new wire format and no client changemodelMessagesToUIMessagesalready merges a stored result into the call it belongs to and completes the card.Each recorded call carries
metadata.sandboxObserved, which does two jobs. It is stripped from the request to the model on the next turn — those calls name tools the provider was never given, and one run of them is far too many tokens to replay — and it lets an app's ownMessageStorefind them insaveThreadto cap or drop what it does not want to keep. Nothing is truncated for you; results are handed over whole. Seedocs/sandbox/events.md.One public addition,
isSandboxToolCall(toolCall)from@tanstack/ai-sandbox: true when a tool call was executed by the harness inside the sandbox and recorded for display. It reads the marker so an app never has to know the metadata key, and it also accepts a renderedtool-callpart, whosemetadatais copied from the message — so the same helper filters a client-side view. Everything else is internal: the disconnect seam is exposed only through@tanstack/ai/adapter-internalsfor@tanstack/ai-sandbox, and the recorder itself is private towithSandbox.Durable sandbox instance resume for multi-process / multi-replica deploy.
- breaking
SandboxInstanceStore/SandboxInstanceRecord/InMemorySandboxInstanceStore/SandboxInstanceStoreCapabilityin@tanstack/ai-sandbox - breaking
withSandbox(sandbox, { instances, locks? })takes the store directly, so it cannot be mis-ordered (in-memory fallback when absent).SandboxInstanceStoreCapability+provideSandboxInstanceStoreremain for ambient/platform wiring; an explicit option wins over the bus. - breaking
defineSandboxInstanceStorefor inline BYO typing (same pattern asdefineLock/defineMessageStore) - breaking
Pair multi-instance with
withLocksfrom@tanstack/ai/locks(distributed lock) - breaking
Independent of chat persistence — compose both when the app needs transcript durability and instance reuse
- breaking
Conformance:
runSandboxInstanceStoreConformancefrom@tanstack/ai-sandbox/testkit
SandboxCapabilities.killableProcessesis now either measured or falsifiable on every bundled remote provider, instead of asserted in a comment.The flag is not cosmetic:
journalReadStrategyreads it to pick'follow'(a spawnedtail -c +N -f) over'poll'(boundedexecreads). A wrongtruestarts a process the host then cannot stop, leaking one per run. Two providers were recently probed and both declared it falsely, so the remaining declarations were audited rather than trusted.- breaking
Vercel —
killableProcessesis nowfalse. Itskill()only calledcontroller.abort(), and that signal reaches nothing:@vercel/sandboxforwards a detached command'ssignalto the HTTP request that starts the command (already resolved by the time the handle exists) and to a log pipe this handle does not use.kill()was a no-op that left the remote process running — the same client-side-detach shape as the Docker defect.kill()now issues the SDK's real server-sideCommand.kill('SIGKILL')(and the caller'ssignalroutes through the same path, best-effort so teardown cannot throw), but whether that reaches the forkedtail -fis unmeasured, so the capability staysfalseand reads poll. - breaking
Daytona —
killableProcessesis nowfalse.kill()aborts the client-side poll loop and resolves before anything attempts termination; thedeleteSessionthat was supposed to be the kill runs later from the pump's teardown, swallows its own failures, and is documented as cleanup for a completed session. - breaking
Sprites — left
true, because itskill()is a genuine server-sidePOST /exec/<sessionId>/killrather than a stream detach — but the declaration is now labelled unverified: what that endpoint signals (process group or pid) is undocumented, and the follow command is a multi-statement shell whosetail -fis necessarily a child. - breaking
Cloudflare —
falseconfirmed by behavior, not by reading the constant: a test drives a still-running command, callskill(), and asserts nothing was cancelled and noAbortSignalever reachedexec(Workers RPC cannot serialize one), then thatjournalReadStrategyanswers'poll'.
Daytona, Vercel, and Sprites each register the shared journal conformance suite. With credentials present it measures the claim against a real sandbox; without them it reports a named skip carrying the reason, never a silent pass.
Separately,
@tanstack/ai-sandbox-cloudflare'spipeToRunLognow honors its documented "never rejects" contract.log.open, the terminallog.finish, the recoveryappend/finish, and the final record re-read were all unguarded, andRunController.startconsumes the promise fire-and-forget — so any of them rejecting was an unhandled rejection, which is instance-fatal inside a Durable Object. Every call is now individually guarded and reports through an optionallogger, a failing re-read falls back to a locally rebuilt terminal record instead of throwing, and the fire-and-forget hand-offs use two-argumentthenrather than.finally(which adopts rejections).Make a mid-stream reload resume the same conversation cleanly.
- breaking
withPersistencenow persists the pending turn at the start of a run (so a reload during generation still shows the user's message), stamps each assistant turn with its streammessageId, and acceptswithPersistence(persistence, { snapshotStreaming: true })to also persist the in-progress reply on a throttled interval (snapshotIntervalMs, default1000) for partial-output durability. - breaking
ModelMessagegains an optionalid;modelMessagesToUIMessagespreserves it, so a hydrated message keeps the same identity as its live stream. - breaking
On reload, the chat client rebuilds an in-flight assistant turn from the delivery log (replaying from the start and applying the buffered backlog in one batch) instead of reconciling against the persisted partial, so the reload shows one clean bubble that catches up and continues rather than a frozen or duplicated partial.
Make generation persistence work with server functions and direct connections. Server-driven restore (
persistence: true) previously only worked with the HTTP adapters (fetchServerSentEvents/fetchHttpStreamand their XHR variants), because they are the only connections that implement the optionalhydrateGeneration(threadId)andjoinRun(runId)handlers; withstream(),rpcStream(), or a plainfetcherthe option silently no-opped, and a stored snapshot stillrunningafter a reload left the hook stuck ongeneratingforever.Handlers on the lightweight adapters (
@tanstack/ai-client).stream()andrpcStream()take an optional second argument,StreamConnectionHandlers({ hydrate, hydrateGeneration, joinRun }), spread onto the returned adapter so server-driven persistence works without an HTTP endpoint — each handler is typically a one-line server-function or RPC call.ConnectConnectionAdapteralso declares the optional chathydratehandler alongside the generation ones.Handlers as generation options (
@tanstack/ai-client).GenerationClientOptions(andVideoGenerationClientOptions, plus every framework hook's generation options) accept optionalhydrateGeneration/joinRunalongside afetcher— or as a fallback when a connection doesn't carry its own.persistence: truenow hydrates whenever either source exists; the constructor warning only fires when neither does.Interrupted runs no longer stick on
generating(@tanstack/ai-client). A restored or hydrated snapshot withstatus: 'running'that nojoinRunhandler can tail is repainted as an interrupted error — an interrupted generation cannot be resumed, only re-run — in bothGenerationClientandVideoGenerationClient.Request-free hydration (
@tanstack/ai-persistence). NewgetGenerationHydration(persistence, id, { by?: 'threadId' | 'runId' })returns the plain{ resumeSnapshot, activeRun }payload straight from thegenerationRunsstore, so a server function can backhydrateGenerationwithout fabricating aRequest.reconstructGenerationnow delegates to it;authorizestays on theRequest-based function only, so server-function callers gate on their own session before resolving the id.Server-function run replay (
@tanstack/ai).memoryStreamalso accepts an explicit{ runId, offset? }init instead of aRequest, and a newreplayRunStream(durability, offset?)async generator maps a durabilityread(from the start by default) to a bareStreamChunkstream — together they let a streaming server function servejoinRunfor a run id it received as call data.Add a shared
Scopeidentity type to@tanstack/ai.Scopeis the single identity/isolation vocabulary for the subsystems that persist or recall per-conversation data —@tanstack/ai-persistenceand@tanstack/ai-memory. Rather than each subsystem inventing its own notion of "whose data is this?", both import one type:interface Scope { threadId: string // required — the single conversation key (same as ctx.threadId) userId?: string // durable end-user identity; required in practice for multi-user apps tenantId?: string // multi-tenant boundary namespace?: string // reserved logical partition; no subsystem keys on it yet }threadIdis the one conversation key across the codebase (matchingChatMiddlewareContext.threadId, withconversationIdalready deprecated in favor of it) — subsystems must not introduce a second name (sessionId, …) for the same concept. Every field is an isolation boundary and must be derived server-side from trusted session state, never from client input.Introduced ahead of the persistence and memory packages so both share one settled identity contract.
@tanstack/ai-memorynow aliasesMemoryScopetoScope(see the memory-scope-threadid changeset).Make every bundled Agent Skill discoverable by TanStack Intent.
Intent finds skills by scanning
node_modulesfor packages that carry thetanstack-intentkeyword, and can only load what npm actually publishes. Three packages shipped skills that failed one half of that contract:- breaking
@tanstack/ai-mcpwrote its skill into askills/directory that was missing fromfiles, so it was never published at all. - breaking
@tanstack/ai-memoryand@tanstack/ai-sandboxpublished their skills but lacked the keyword, so Intent never looked at them.
All three now publish
skillsand carry the keyword, matching@tanstack/ai,@tanstack/ai-code-mode, and@tanstack/ai-persistence.The client persistence skill also moves from
@tanstack/ai-persistenceto@tanstack/aiasai-core/client-persistence. It teacheslocalStoragePersistence/sessionStoragePersistence/indexedDBPersistenceand thepersistenceoption onuseChat— all of which live in the framework packages, not in@tanstack/ai-persistence. An app doing browser-only persistence never installs that package, so the guidance was unreachable for exactly the people who needed it, andai-corerouted to a path that did not exist on disk. Skills now follow the code that owns them.Make streaming
summarize()resumable across a mid-run reload, like the media activities. Additive, no public API change beyond two optional fields:- breaking
summarize()(andSummarizationOptions) accept optionalrunId/threadId. When set on a streaming summarize, they are threaded into the wrapped chat so the emittedRUN_STARTEDcarries the caller'srunId— letting a delivery-durable route key the run's log by the same id the client rejoins with, so a mount-timejoinRuntails the run to completion instead of fast-failing on a mismatched (empty) log. - breaking
@tanstack/openai-base's ResponseschatStreamnow honorsoptions.runIdfor the AG-UIRUN_STARTED(mirroring how it already honorsoptions.threadId), falling back to a generated id when unset.
Update model metadata from OpenRouter API
Breaking: trim the persistence public API down to what an app actually calls.
Generation persistence is server-driven, so the types and options that only existed to support a client-managed copy of a run are gone.
- breaking
initialResumeSnapshotis removed from every generation hook (useGeneration,useGenerateImage,useGenerateVideo,useGenerateAudio,useGenerateSpeech,useSummarize,useTranscription, and the Solid / Vue / Svelte / Angular equivalents) and fromGenerationClient/VideoGenerationClient. A run is restored bypersistence: trueplus ahydrateGenerationhandler.useChatkeeps itsinitialResumeSnapshot. - breaking
No longer exported from
@tanstack/ai-client(they are internals of the hydration path):GenerationResumeSnapshot,GenerationResumeState,GenerationResumeStatus,GenerationResultSnapshot,GenerationErrorSnapshot,GenerationEventSnapshot,GenerationPendingArtifact,parseGenerationResumeSnapshot,updateGenerationResumeSnapshot, andChatResumeSnapshot.GenerationPersistenceOption(an alias forboolean) is deleted; writepersistence?: boolean.GenerationPersistenceOptions, the union that requires athreadIdalongsidepersistence, is unchanged. - breaking
ChatResumeSnapshotV1/ChatResumeSnapshotV2are collapsed into one shape with noschemaVersionfield. The two versions were structurally identical, no reader branched on the version, and only V2 was ever written. - breaking
The framework packages no longer re-export
PersistedArtifactRef. No hook type refers to it; import it from@tanstack/aiwhere the artifact stores are defined. - breaking
artifactBlobKeyis no longer exported from@tanstack/ai-persistence. UseresolveArtifactBlobKey(record), which its own docs already recommended for reads, since a record written with a customstorageKeycarries its real key. - breaking
createInterruptControllerandInterruptControllerare deleted. The controller only forwarded five calls to theinterruptsstore; call the store directly (persistence.stores.interrupts). - breaking
The ctx-capability plumbing (
PersistenceCapability,InterruptsCapability,getPersistence,providePersistence,getInterrupts,provideInterrupts) is unchanged and now documented, for middleware that reads the storeswithPersistenceholds. See Persistence internals.
The chat hooks no longer take an
idoption — a hook's identity is itsthreadId.useChat/createChatpreviously accepted a separateidthat keyed client persistence and named the devtools instance, defaulting to a frameworkuseId()when omitted. That meant persistence keyed on an ephemeral render-tree id even when you passed a stablethreadId, so a reload found nothing under the thread's key.Now the
threadIdis the single identity:- breaking
The hooks drop the
idoption. PassthreadIdto persist a conversation and restore it on reload; omit it for an ephemeral chat. - breaking
Persistence keys on
threadId(unchanged inChatClient, which already resolvedid ?? threadId— the hooks simply stop overriding it). - breaking
ChatClient.uniqueId(the devtools instance id) now falls back tothreadIdinstead of a generated id, so a thread shows up in devtools under its own id. - breaking
Changing
threadIdon a mounteduseChat(react/preact/solid) now recreates the client so the new thread takes effect; previously the change was ignored.
ChatClientstill acceptsiddirectly as a lower-level escape hatch for keying storage separately from the wire thread; only the framework hooks drop it.Migration: replace
useChat({ id })withuseChat({ threadId }).- 0.2.x
0.2.3
20 Jul 2026Reading the note…
0.2.2
16 Jul 2026Reading the note…
0.2.1
6 Jul 2026Reading the note…
0.2.0
30 Jun 2026Reading the note…
- 0.1.x
0.1.0
30 Jun 2026Reading the note…