@tanstack/ai
0.71.0 · 21 Oct 2026MIT593.9k / weekRepositoryHomepagenpmAtom feed
103 stable releases, newest first, by release line. Notes are quoted as the maintainers published them.
- 0.71.x · latest line
0.71.0
21 Oct 2026Add computer use that runs the same code on OpenAI and on Anthropic. A driver controls the machine. The Playwright and sandbox drivers give each thread its own machine.
New packages:
Changes in existing packages:
const computerUse = createComputerUse({ driver: playwrightComputer({ startUrl: 'https://tanstack.com' }), }) chat({ adapter, messages, threadId, tools: computerUse.tools, middleware: [computerUse.middleware], interrupts: [computerUse.interrupt], }) await computerUse.close(threadId) // one thread await computerUse.close() // every thread, and the browserThe
ai-coreagent skill routes computer use to@tanstack/ai-computer-useand its three drivers.
- 0.70.x
0.70.0
19 Oct 2026Subagent limits, and provider keys for agents.
A subagent can now return a plain value and call any activity.
Check a raw JSON Schema tool input before the tool runs. This covers plain JSON Schema tools and every MCP tool.
Behavior change:
Migration: if a JSON Schema tool must accept input that its schema rejects, widen the schema.
Minor Changes
Subagent limits, and provider keys for agents.
subagents.limits:maxDepth,maxConcurrent,maxCalls, andtimeoutMsfor the whole tree of children the model starts through tools. The tree shares one budget (SubagentBudget), which a child'sctx.chat({ subagents })passes on, so a child cannot reset it. A refused start reaches the model as a tool error.subagents.keysandctx.keys(ProviderKeys:get,require, andadapter). Pass an object by provider id, for example{ openai: userKey }, or a function, for example(provider) => getByokKey(request, provider). An agent builds akeyedAdapterwithawait ctx.keys.adapter(adapter). A missing key falls back to the provider'senvnames. When there is no key at all, the call throws an error that names the env var. A child'sctx.chat({ subagents })passes the keys on to its own children. Keys that the child sets itself win for those providers.
A subagent can now return a plain value and call any activity.
defineAgent'sruncan return a promise of any value, such as an image result. The value arrives onSUBAGENT_FINISHED.result, and the parent model gets it as the tool result. A very long string inside an object result (for example a base64 image) reaches the parent model as a short note (compactForModel). A string result also streams as the child's text. The result ofctx.chat({ stream: false })streams like the chat stream.rungets the activities onctx(ctx.chat,ctx.generateImage,ctx.generateVideo, and the rest), which fill in the thread id, a run id, and the abort signal.ctx.forwardholds the fields for a plainchat()call. The optionalproducesfield says what an agent makes.A host can set
subagents.binding(SubagentBinding) to add chat and generation middleware to every activity call of a child. A child'sctx.chat({ subagents })passes it down to its own children.Children inherit the prompt cache retention of the parent call.
The chat middleware context has
subagentNameandparentSubagentRunId.chat()takes both as options.RunRecordgets optional fields for host sessions (kind,activity,agent,result,artifacts,principal,leaseOwner,leaseExpiresAt,checkpoint). The memory run stores keep them.
Check a raw JSON Schema tool input before the tool runs. This covers plain JSON Schema tools and every MCP tool.
Behavior change:
A tool with a JSON Schema
inputSchemanow gets a tool error (stateoutput-error) when the input does not fit the schema. The tool does not run. Before, any input ran.The check runs at the same place as the Standard Schema check: after approval edits and middleware
onBeforeToolCall.The draft comes from
$schema(draft-04, draft-07, 2019-09, 2020-12). With no$schema, the check uses 2020-12.formatis not checked.OpenAPI 3.0 schemas work:
nullable: truelets the field benull, and a booleanexclusiveMinimumorexclusiveMaximumworks in every draft.The error names each failed location (up to 10) and shows the received arguments:
Input validation failed for tool <name>: #/count: ....A schema that the check cannot use, such as one with a remote
$ref, gives a tool error on each call.The check uses
@cfworker/json-schema. It does not compile code, so it runs on Cloudflare Workers.
Migration: if a JSON Schema tool must accept input that its schema rejects, widen the schema.
Add the
onModelErrormiddleware hook andctx.retry(). When a model call fails before it streams output,onModelErrorgets theRUN_ERROR. Callctx.retry()there to drop the failed call, runonConfigagain, and call the model again. A model call retries at most 2 times this way.ctx.retry()throws in any other hook.Keep the provider's raw tool argument string for replay.
Adapters now send the raw argument string as
argsonTOOL_CALL_END.The tool call in the history keeps that exact string when it is the same JSON value as the parsed input, so the next request sends the provider's own bytes back (for example
1.50, or an id pastNumber.MAX_SAFE_INTEGER). When the parsed input differs, only the changed values are written again.The
TOOL_CALL_ENDthat reaches the client carries the same string inmetadata.tanstack.args, so the client history matches the server history. It no longer also carriesmetadata.tanstack.input: in-processchat()andStreamProcessorparseinputfromargs. Run the client and the server on the same release: an older client ignoresargs, so it builds the tool input from the streamed deltas only, and a tool call with no deltas (for example an Anthropic server tool) gets no input.History converters for Bedrock and the Gemini Interactions API send malformed tool arguments as
{}instead of failing the request.Gemini Interactions: the initial
step.argumentssnapshot goes out as aTOOL_CALL_ARGSdelta only when no argument deltas follow, so the deltas always add up to the final arguments.Malformed tool arguments are no longer turned into
{}by an adapter. The tool gets a "Failed to parse tool arguments as JSON" error that the model can repair. Mistral still ends the run with an error on malformed arguments, as before.
Continue a child. With
withPersistenceon the parentchat(), an agent tool result gives the model the child'ssubagentRunId, and a later call of the same agent tool withsessionIdcontinues that child from its stored transcript. With a host that setssubagents.binding.start,background: truestarts the child and returns at once. These fields sit next to the agent's own fields, and each agent tool has them only when the run can use them. When the run usessessionIdorbackground, that name is reserved:chat()throws when an agent'sinputSchemauses it. An agent withoutinputSchematakes the task as text inprompt, and the child gets it as the last user message. Agent tools also support thedetachfield of the tool context.The card keeps the checked input of an agent with
inputSchema. On the client,part.subagent.inputhas it, typed frominputSchemawhen the agent declaration goes touseChat({ subagents }).@tanstack/aiaddsLoadChildCapability,getLoadChild, andprovideLoadChild, so a persistence middleware can load a stored child. A provider listsLoadChildCapabilityinprovides.withPersistencein@tanstack/ai-persistenceprovides it: it stores the task of each child's first call and the thread that started it, and loads only the children of the calling thread.Repair common model mistakes in a tool input before the final input check.
When a JSON Schema tool's input (and every MCP tool's input) fails the check, it is repaired and checked again:
"5"as5(decimal text only),"true"astrue,5as"5"for a string, andnullas"",0, orfalsefor a required field. Anullfor an optional field that does not allownullis removed. The repair follows unions,$ref,if/then/else,prefixItems,patternProperties, andadditionalProperties.A Standard Schema runs first as written. When it fails and the schema can export JSON Schema (Zod, ArkType, Valibot), the input is repaired and the schema runs again. A failure reports the issues of the first run.
The received arguments do not change. The tool gets the repaired copy.
New
chat({ repairToolInput })option.falseturns the repair off. A function replaces the built-in repair: it gets{ tool, toolCall, input, error }and returns the input to check again, orundefinedto send the validation error to the model.
Behavior change: a call that failed the check only because of these mistakes now runs with the repaired input.
Check a tool input at the last moment, just before the tool runs or the client gets the call.
Behavior change:
The tool's Standard Schema
inputSchemanow checks the input after approvaleditedArgsand after middlewareonBeforeToolCall. Before, atransformArgsvalue reachedexecuteor the client without a check. An input that does not fit the schema now becomes a tool error (stateoutput-error), and the tool does not run. An approval edit is also still checked when the run resumes.onBeforeToolCallgets the parsed input before the schema check. It has no schema defaults or transforms yet, and it can be invalid.Async Standard Schema input validators are awaited. Before, every call to such a tool failed with an input validation error.
onBeforeToolCallnow also runs for a client tool before the server sends the call to the client. This includes a client request at an interrupt boundary. It does not run while a call waits for approval or for a denied call. When the client result comes back, the result is kept, with no second hook run and no second check.otelMiddlewarerecords noexecute_toolspan for a client tool, because the server does not run it.A validation error after the hook runs
onAfterToolCallwithok: false.The tool error that the model sees now also shows the arguments that the model sent. When an approval edit or middleware changed them, the error says so.
While a call waits for approval, an approved client tool in the same batch is now sent to the client. Before, it waited for the next round trip.
When the only call that waits for approval fails its input check, the other calls of the batch now run. Before, they got no result.
Migration:
If a middleware
transformArgsor an approval edit sends a value that the schema rejects, make the value fit the schema, or widen the schema.If
onBeforeToolCallreadshookCtx.args, check its shape first. Do not expect schema defaults there.If a middleware counts or blocks calls in
onBeforeToolCalland must see only server tools, return early whenhookCtx.tool?.executeis missing.
Patch Changes
decide()now reports token usage when the provider answered but the answers cannot be mapped, for example when an answer is missing, has the wrong type, or has no probability for its value. Before,decide()threw without reporting the usage that the provider already billed. TheonUsagemiddleware hook and theevaluate:usageevent now get that usage once, beforeonErrorruns.onFinishstill does not run for a failed call, anddecide()throws the mapping error. If anonUsagehook throws, that error is thrown instead, as it is for a successful call. The mapping error is still logged.Keep custom metadata from
REASONING_MESSAGE_STARTon the assistantUIMessage. The thinking text was already kept. Metadata that arrived only on the start event was dropped.File each model call on its own assistant message. Reasoning that arrives before the call's text id, a tool call whose parent id is new, and a signature that names an earlier step stay on the message that owns them.
Fix subagent limit and continue edges.
A background
startthat throws gives themaxCallsslot back.maxConcurrentcounts that child untildonesettles.startnow returnsdone.A schema result that is not an object does not drop
sessionIdorbackground. A top-levelanyOfschema still shows those fields.A suspended child with no stored
basekeeps its transcript.When the parent deadline is already past, the model gets
subagent limit reached (no time left).
- 0.69.x
0.69.0
13 Oct 2026Keep the order of Claude's thinking, text, and tool calls in one answer.
chat()can now retry a rate-limited model call by itself. Passretry: { maxRetries, maxWaitMs? }. When the provider sends a wait time (retryAfterMs) with an error,chat()waits that long, then calls the model again.retryis off by default.chat()now also sendsRUN_STARTEDbefore aRUN_ERRORwhen the adapter failed before it sent a start.Text adapters now set one stable code on
RUN_ERRORwhen the input does not fit in the model's context window:code: 'context_overflow'.@tanstack/aiexports it asCONTEXT_OVERFLOW_CODE. The provider's own text stays inmessage.if (chunk.type === 'RUN_ERROR' && chunk.code === CONTEXT_OVERFLOW_CODE) { // shorten the messages, then call chat() again }These adapters set it: OpenAI and the other OpenAI-compatible adapters (from
code: 'context_length_exceeded'), Anthropic, Gemini, Bedrock (Converse, Chat Completions, and Responses), OpenRouter, Grok, Cloudflare Workers AI, and BytePlus.Changes to code that reads the old values:
Minor Changes
Keep the order of Claude's thinking, text, and tool calls in one answer.
Claude can answer with thinking, a tool call, more thinking, and another tool call. The stream, the stored messages, the client wire, and the next request to Claude keep that order.
ModelMessagehas a new optionalblockOrderfield. The library writes it only when the order is not the default (thinking, then text, then tool calls), so a message in the default order does not change.orderedAssistantBlocks(message)gives adapter authors the blocks in order.Each Claude thinking block ends with its own
REASONING_MESSAGE_ENDandREASONING_END.Text after a second thinking block starts a new text part.
On the client wire, an answer in another order goes out as ordered rows. A row that exists only for the order has
metadata.tanstack.continues, and the server joins it back into one message. AUIMessagethat holds two model calls with a tool result between them reaches the server as assistant, tool, assistant, for every adapter.
chat()can now retry a rate-limited model call by itself. Passretry: { maxRetries, maxWaitMs? }. When the provider sends a wait time (retryAfterMs) with an error,chat()waits that long, then calls the model again.It retries only when the provider sent a wait time.
It does not retry when the wait is longer than
maxWaitMs(default 60 000).It does not retry a call that already streamed output, so no output repeats.
An abort stops the wait. You then get the
RUN_ERROR.It does not retry the structured-output call.
A
maxRetriesormaxWaitMsthat is not valid throws aTypeError.
retryis off by default.chat()now also sendsRUN_STARTEDbefore aRUN_ERRORwhen the adapter failed before it sent a start.Text adapters now set one stable code on
RUN_ERRORwhen the input does not fit in the model's context window:code: 'context_overflow'.@tanstack/aiexports it asCONTEXT_OVERFLOW_CODE. The provider's own text stays inmessage.if (chunk.type === 'RUN_ERROR' && chunk.code === CONTEXT_OVERFLOW_CODE) { // shorten the messages, then call chat() again }These adapters set it: OpenAI and the other OpenAI-compatible adapters (from
code: 'context_length_exceeded'), Anthropic, Gemini, Bedrock (Converse, Chat Completions, and Responses), OpenRouter, Grok, Cloudflare Workers AI, and BytePlus.Changes to code that reads the old values:
OpenAI-compatible adapters gave
code: 'context_length_exceeded'. They now give'context_overflow'.rawEventstill has the provider's code.Anthropic, Grok, OpenRouter, and BytePlus gave the HTTP status or a general bad-request code for this error. Gemini and Bedrock Converse gave no code.
The Anthropic adapter now sends
RUN_ERRORwith this code when the model stops withstop_reason: 'model_context_window_exceeded'. Before, it sentRUN_FINISHEDwithfinishReason: 'stop', so a cut-off answer looked complete.
New hooks for hosts that run
chat()for a long session.@tanstack/aiexportsrunAgentStream(runs adefineAgentagent outside a parent chat turn),createSubagentId,createSubagentSink,CapabilityRegistry, and theSubagentsBagandSubagentSinktypes. Pass the sink to keep interrupt objects and token usage. The finished event hasinterruptIdsonly.MetadataStorehas optionalgetVersionedandsetIffor compare-and-set writes. The memory store of@tanstack/ai-persistencehas both, and the conformance testkit checks them.toolDefinition({ replay: 'safe' | 'never' })tells a host if it can run the tool again after a crash.The tool context has an optional
detachfield. A host sets it when it can move the call to the background. A tool passes its work todetachand races the two promises.A tool that throws the MCP input-required shape can set the interrupt
reason. The default is'mcp_input'. The interrupt id staysmcp_input_<toolCallId>.
toJsonResponsenow accepts the object fromchat({ outputSchema }). It sends the object as a complete structured output, souseChatwithfetchJsonshows it as astructured-outputpart. Before this change, the call failed withCannot read properties of undefined (reading 'map').toJsonResponsealso awaits a Promise input, sotoJsonResponse(chat({ ... }))works.Add
LogRecordsCapabilityand theLogRecordandLogRecordsWritertypes. A chat middleware can append records to a session log when a host middleware provides one. Read the writer withgetLogRecords(ctx, { optional: true }). It isundefinedwhen no host provides the log, andgetLogRecords(ctx)throws. A host provides the writer insetupwithprovideLogRecords(ctx, writer).@tanstack/aidoes not provide the log.Keep the prompt cache when tools or system prompts are added during a conversation.
Before each model call,
chat()compares the tools and the system prompts with the earlier calls. On a model with a mid-conversation channel, an added tool, or a system prompt added at the end, goes out in the conversation. The start of the request stays the same.openaiTextongpt-5.4-mini,gpt-5.5,gpt-5.6-luna,gpt-5.6-sol,gpt-5.6-terra,gpt-6-astra,gpt-6-luna, andgpt-6-sol: an added tool is anadditional_toolsinput item, and an added prompt is adevelopermessage. The Responses adapter of@tanstack/openai-basesends these when a subclass setsmidConversationChannels, and it sends thenamespaceof such a tool's call back on the next request.anthropicTextonclaude-opus-4-8,claude-opus-5,claude-opus-5-5,claude-fable-5, andclaude-fable-5-1: every request with tools has themid-conversation-tool-changes-2026-07-01beta and one placeholder tool, unless a provider tool such aswebSearchTool()takes part. An added tool hasdefer_loading: trueand atool_additionblock in asystemmessage.Every other model and adapter sends the same request as before.
The channels are on by default only with the provider's own API. A custom
baseURL, a customfetch, an injected client, or theOPENAI_BASE_URL/ANTHROPIC_BASE_URLenvironment variable turns the default off. SetmidConversationChannels: trueto turn the channels on anyway. Setfalseto turn them off anywhere.With prompt caching on (the default), the automatic Claude tool cache marker goes on the last start tool, not on the placeholder or an added tool.
New optional fields:
ModelMessage.midConversationChange,TextOptions.midConversationChanges, andTextAdapter.midConversationChannels, with the typesMidConversationChange,MidConversationChanges, andMidConversationChannels.splitMidConversationChangeshelps adapter authors read the changes.
A text adapter can now have an optional
compact(options)method. It sends the history to the provider's own compaction endpoint, and resolves to the history for the next calls. The options areTextCompactOptions:messages,model,signal,wrapFetch, andsystemPrompts.The OpenAI Responses adapters implement
compactwithPOST /responses/compact. The request sends the system prompts asinstructions. It sends no tools, because the endpoint does not take them. The encryptedcompactionitem goes back to the same model as it is on later requests. A compact result with no compaction item throws.withCompactionhas a newnativeoption. Pass the adapter of thechat()call. When the adapter hascompact, it runs in place ofstrategy. Whencompactfails,strategyruns, andonCompactandcompaction:endedget the failure inerror. After a 404, 405, or 501, that adapter and model use the strategy for the rest of the process. After any other failure, the next compaction triescompactagain. Without the option, nothing changes.Prompt caching is now on by default. Each
chat()call asks the provider to cache the stable start of the request (system prompt, tools, and earlier messages), so repeated requests cost less and answer sooner.New
chat()optionpromptCache:'none' | 'short' | 'long', or{ retention, key }. The default is'short'. The key ispromptCache.key, else thethreadIdorconversationIdthat you give. AthreadIdthatchat()makes up is never the key.To turn it off, pass
promptCache: 'none'. Do this for a large prompt that you send to Claude one time only, because Claude bills the cache write.A middleware can change it for the next model call:
onConfiggetsconfig.promptCacheand can return a new value.Each adapter sends its own fields:
Claude on Anthropic, Bedrock Converse, and OpenRouter gets cache markers.
'long'asks for the 1-hour TTL.OpenAI (Responses and Chat Completions) gets
prompt_cache_key, andprompt_cache_retentionorprompt_cache_optionson'long'.Mistral gets
prompt_cache_key, and usage now reports cache reads.OpenRouter gets
sessionId.
A manual
cache_control,cachePoint,prompt_cache_key, orsessionIdwins over the automatic value.New types:
PromptCacheRetention,PromptCacheOptions, andResolvedPromptCache. Adapters readTextOptions.promptCache.
Add the
reasoningoption tochat(). Set how hard the model thinks in one way for every provider.Pass a level:
off,minimal,low,medium,high,xhigh, ormax. Or pass{ level, summary?, budgetTokens? }.The types allow only the levels of the selected model.
budgetTokensis allowed only on a model that thinks with a token budget.At run time, a level that the model does not have moves to the nearest level that it has.
Without
reasoning, the provider default applies.A middleware can change the value in
onConfig.
Breaking: none. Each adapter package removes its own reasoning fields from
modelOptions. See the changeset of each adapter.chat({ adapter: openaiText('gpt-5.5'), messages, reasoning: 'high' }) chat({ adapter: anthropicText('claude-haiku-4-5'), messages, reasoning: { level: 'medium', budgetTokens: 4000 }, })New exports:
REASONING_LEVELS,supportedReasoningLevels,clampReasoningLevel, and the typesReasoningLevel,ReasoningLevels,ModelReasoning,ReasoningCapability,ReasoningOption,ReasoningOptionFor,ReasoningRequest, andAdapterReasoning.@tanstack/ai/adapter-internalsexportsresolveReasoning,reasoningBudget,DEFAULT_REASONING_BUDGETS, and theModelReasoningCapabilitytype for adapter authors.TextAdapterandBaseTextAdaptertake a new last type parameter for the reasoning levels. Its default isnever, so an adapter that does not set it takes noreasoning.Keep a conversation valid when the next request goes to a different model.
Each assistant message records
metadata.tanstack.source({ provider, api, model }), andresponseIdand the resolvedmodelwhen the provider sends them. A failed or aborted answer getsstopReason: 'error'or'aborted'.RUN_FINISHEDhas a newresponseIdfield.TextAdapterhas new optionalproviderandapifields.When the next request goes to a different source, the adapter removes signatures and redacted thinking, turns readable thinking into text, and rewrites tool call IDs to the shape the target API accepts. Each call stays paired with its result.
Every request drops failed and aborted assistant batches and adds a
No result providedresult for an unanswered tool call. The saved history does not change.RUN_FINISHEDandRUN_ERRORmetadata now merge onto the assistant messages of that model call inStreamProcessor.Anthropic: the new
allowEmptySignatureoption (defaultfalse) replays unsigned thinking from the same source, for gateways. The newprovideroption names a gateway.Azure OpenAI reports the
azure-openai-responsesAPI.Gemini, Mistral, and Bedrock Converse send same-source thinking back to the model, in block order.
withPersistencewrites the source and the stop reason onto streaming snapshot rows.Adapter authors get
transformMessagesForReplay()andhashToolCallId()from@tanstack/ai/adapter-internals.
Keep each OpenAI Responses answer item's
idandphase(commentaryorfinal_answer) on the assistant message, inmetadata.tanstack.responseItems. A same-model replay sends each item back with itsidandphase. Another model gets the plain text, as before.A router pick can give an agent its input. Anywhere a pick has a name, it can have
{ name, input }, sosubagents.routercan start an agent withinputSchema.chat()checks the input against the schema before any child starts, andrunreads it asctx.input. The input stays on the server: the plan onSUBAGENT_STARTEDhas the agent names and the order only.subagentRoutegetsneedsInput(result), which lists the picked agents with aninputSchema, andpick(result, { inputs }), which puts each input on its name.inputsis typed from each schema.chat()throws when a pick gives an input tomainor to an agent with noinputSchema, or gives one agent an input twice in one step.chat()also throws when a router picks an agent whoseinputSchemais a plain JSON Schema, because it cannot check that input. A turn with a router input cannot resume after an interrupt, and a resume also fails when its saved plan is absent. The newSubagentPicktype is exported.Add the
toolChoiceoption tochat().chat({ toolChoice })takes'auto','none','required', or{ type: 'tool', name }.In
chat(),namesuggests the names of thetoolsand still takes any string. TheToolChoicetype takes an optional name type for this.A middleware can change it for one model call in
onConfig.A tool choice in
modelOptionswins.No tool choice goes out when the request has no tools.
@tanstack/openai-basesendstool_choicefrom Chat Completions and Responses, and exportstoChatCompletionsToolChoiceandtoResponsesToolChoice.
chat({ adapter, messages, tools, toolChoice: 'required' }) chat({ adapter, messages, tools, toolChoice: { type: 'tool', name: 'getWeather' }, })A tool call in a model answer that stopped at the output limit (finish reason
length) never runs. The model can send it again when the loop strategy goes on.Behavior change:
chat()never runs a tool call of an answer that stopped at the output limit. The input of such a call can be incomplete JSON. The call asks for no approval, and the client does not run a client tool for it. Before, such a call ran in some cases, for example as a client tool with{}input, or as a pending call of the nextchat()request.Each cut call gets an error tool result. The default text tells the model to send the call again.
The default
agentLoopStrategyis nowcombineStrategies([maxIterations(5), untilFinishReason(['length'])]). By default, a cut answer ends the run with the finish reasonlength, as before.With a strategy that goes on after
length(for examplemaxIterations(5)), the model is called again. TheRUN_FINISHEDevent of the cut answer then has the finish reasontool_calls, so the client keeps the stream open.In a history message with
metadata.tanstack.finishReason: 'length', a call input that is not JSON goes to the provider as{}. Before, Bedrock threw on such a cut input.Middleware
onAfterToolCallgets each cut call, withok: falseand anErrorwith the code'max_tokens'. Return{ type: 'replaceResult', result }to change the error text.Each assistant message that
chat()stores on the server keeps the finish reason of its model call inmetadata.tanstack.finishReason.
Migration: if you pass your own
agentLoopStrategyand want a cut answer to end the run, adduntilFinishReason(['length'])to it withcombineStrategies.
Patch Changes
fakeTextfrom@tanstack/ai/testingnow stops onrequest.signal, the abort signalchat()passes to adapters. Before, it read onlyabortController, so code that reads the adapter stream directly (a harness, for example) saw a paced fake keep streaming after a cancel.Text adapters pass
chat()'s adapter constraint again when the project enablesexactOptionalPropertyTypes.TextAdapter.inputModalitiesnow accepts an explicitundefined, which the provider adapters' declarations emit for their per-model lookup.The client
StreamProcessorno longer logs aconsole.warnwhen it skips a tool call of an answer that stopped at the output limit. Skipping such a call is expected.A
ChatClientthat only watches a thread (a second tab, withlive: true) now returns toreadyafter every run. Before, it stayedstreamingafter the second run, because the stream processor ended only the first run it saw.
- 0.68.x
0.68.0
9 Oct 2026fakeText()takes aninputoption, for examplefakeText({ input: ['text', 'image'] }). It sets the fake'sinputModalities, so you can test code that checks what a model accepts. It also sets the adapter's input modality type.Fix a server tool call that the next turn sent twice when you use
withPersistence. When a model call starts with a tool call and no text (often after thinking),StreamProcessorgave that message the id of the next model call. The next turn then replaced the stored answer with a copy that also held the tool call, so the provider got the sametool_usetwice. The client now keeps the id of the message the tool call belongs to.
- 0.67.x
0.67.0
9 Oct 2026Text adapters can now give
inputModalitiesat run time: the input kinds that the model reads, for example['text', 'image', 'document'].undefinedmeans that the adapter does not know.A middleware
onAfterToolCallhook can now replace the result of a tool call. Return{ type: 'replaceResult', result }from the hook.Add
fakeText()at the new@tanstack/ai/testingsubpath. It is a text adapter that answers from a script. Use it to testchat(), tools, and middleware with no network and no API key.
Minor Changes
Text adapters can now give
inputModalitiesat run time: the input kinds that the model reads, for example['text', 'image', 'document'].undefinedmeans that the adapter does not know.TextAdapterhas the new optionalinputModalitiesproperty. ABaseTextAdaptersubclass sets it from its model metadata.The text adapters of OpenAI, Anthropic, Gemini, Mistral, Groq, BytePlus, Grok, OpenRouter, and LLM Gateway set it. A known model gives its input kinds. An unknown model gives
undefined.
A middleware
onAfterToolCallhook can now replace the result of a tool call. Return{ type: 'replaceResult', result }from the hook.The model and the stream (
TOOL_CALL_RESULT) get the new result.Middleware run in order. Each hook gets the result of the hook before it as
info.result.An error result stays an error.
A hook that returns
undefined, or any value that is not areplaceResultdecision, keeps the result.onAfterToolCallafter anonBeforeToolCallskip now gets the parsed skip result, the same value that goes to the model.The new
AfterToolCallDecisiontype is exported from@tanstack/ai.
Add
fakeText()at the new@tanstack/ai/testingsubpath. It is a text adapter that answers from a script. Use it to testchat(), tools, and middleware with no network and no API key.Queue answers with
setResponsesandappendResponses. An answer is{ text?, thinking?, toolCalls?, finishReason?, error? }, or a function that gets{ request, state }. An empty queue fails with "No more fake responses queued".Token usage is an estimate:
ceil(characters / 4). Withcache: true, the part of a request that matches the previous request of the same thread counts as cached tokens.Options:
model,contextWindow, andtokensPerSecondfor stream pacing. The stream stops when the request aborts.RUN_STARTEDcarriesparentRunIdwhen the call has one.The default tool call id is
fake-call-<fake>-<call>-<index>. It is unique acrossfakeText()instances.
Add
keyedAdapter(provider, create)andisKeyedAdapter(value). Import them from@tanstack/aior@tanstack/ai/byok.keyedAdapterwraps an adapter factory that needs a provider key.provideris a BYOK descriptor (for exampleopenaiByok) or a provider id. It throws when the provider id is not valid.A host finds the key, for example with
getByokKey(request, keyed.provider), and callskeyed.create(key)just before the call. The key is not in your code.isKeyedAdaptertells a keyed adapter apart from a plain adapter.It works for every adapter kind: text, image, speech, audio, video, and the rest.
For several providers, use
keyedAdapters({ openai: (key) => ..., anthropic: keyedAdapter(anthropicByok, ...) }). The map key is the provider id, so a key goes only to its own provider. An entry that is a keyed adapter for another provider throws at startup. In a route,keyedAdapterFromRequest(request, models)from@tanstack/ai/byok/serverbuilds the adapter of the provider that has a key. The user'sx-byok-<id>header wins, then the env names of descriptor entries, in map order. It givesnullwhen no provider has a key.A
RUN_ERRORnow tells you how long the provider asks you to wait before a retry.@tanstack/ai:RunErrorEventhas a newretryAfterMsfield, in milliseconds.chat()moves it tometadata.tanstack.retryAfterMs. The newtoRetryAfterMs(error)helper on@tanstack/ai/adapter-internalsreads theretry-after-msheader first, then theretry-afterheader in seconds or as an HTTP date.@tanstack/openai-baseand@tanstack/ai-anthropic: the text adapters setretryAfterMson aRUN_ERRORwhen the error response has one of these headers. This includes every adapter that uses@tanstack/openai-base.
Add
wrapFetchtochat(), to the middlewareonConfigconfig, and toTextOptions. A wrapper gets the next fetch and gives back a new fetch. Use it to change the URL, the headers, the request, or the response of a model call. The newFetchWrappertype names the wrapper.The engine chains the
chat()wrapper and the middleware wrappers into one function. A middleware wrapper runs inside thechat()wrapper and applies to one model call only.These text adapters send their requests through the wrapper: every adapter on
@tanstack/openai-base(OpenAI, the OpenAI-compatible adapters, Grok, Groq, BytePlus, LLM Gateway, Lovable, Vercel AI Gateway, Cloudflare, and the Bedrock Chat Completions and Responses APIs), Anthropic, Mistral, Ollama, and OpenRouter. The wrapper wraps the fetch of the adapter config, not the global fetch. An Anthropic or Ollama adapter with an injected client ignoreswrapFetch. In Cloudflare binding mode, the wrapper runs, but the binding does not send the URL or the headers. WithoutwrapFetch, the requests do not change.
- 0.66.x
0.66.0
8 Oct 2026- breaking
chat({ stream: false })andstreamToText()now return aChatResultwithtextandchunks, not a string. The chunks keep the tool calls and interrupt outcomes that the string lost. To migrate, changeconst text = await chat({ stream: false })toconst { text } = await chat({ stream: false }).Add a JSON transport for hosts that cannot stream a response. On the server,
toJsonResponse()sends a run as one JSON body, andresumeJsonResponse()sends the rest of a durable run. On the client, thefetchJson()connection adapter reads that body and asks again until the run is done. Each framework package re-exportsfetchJson. Keep the internal subagent run id out of synthetic tool result content.
Merge assistant segments that share a stable UI message ID after conversion.
- 0.65.x
0.65.1
7 Oct 2026StructuredOutputPart.datais no longer optional whenstatusis'complete'. After you checkpart.status === 'complete', TypeScript knowspart.datais set.Migration: if you validate messages with a schema typed as
UIMessage(for examplez.ZodType<UIMessage>), split thestructured-outputpart bystatus. Give'complete'a requireddatafield.Add
isHydratingtouseChat(andinjectChat,createChat) and anonHydratingChangecallback plusgetIsHydrating()toChatClient. It istruewhile the chat is rebuilt from persistence (the server hydrate withpersistence: true, or an async storage adapter). It turnsfalsewhen the transcript is in place and any in-flight run is re-joined, or when the load fails.
0.65.0
6 Oct 2026Only updates dependencies, nothing else changes.
- 0.64.x
0.64.1
5 Oct 2026Stream durable responses live again. With
durabilityset, a chunk waited in the append batch until 32 chunks arrived or the run finished, so a short reply showed up all at once at the end. Now the batch also flushes when the model sends no new chunk for 50ms. You do not needbatch: 1for live text any more.batchstays the largest number of chunks in one append.Set the wait with the new
batchWaitMsoption ondurability(toServerSentEventsResponse,toHttpResponse) and ontoWebSocketStream/toWebSocketResponse. A higher value means fewer writes to the log but slower live text.0appends every chunk on its own.
0.64.0
2 Oct 2026Run the server tools of one model turn at the same time.
chat()used to run them one after another, so a turn with several slow tools took as long as all of them together.snapDurationandsnapToDurationOptionaccept seconds (6), a numeric string ("6"), a seconds template ("6s"), or a keyword the model lists ("auto").durationToSecondsreads the numeric forms. Sora (sora-2,sora-2-pro) accepts4 | 8 | 12,"4" | "8" | "12", or"4s" | "8s" | "12s"and sends"4" | "8" | "12". Lovable Veo accepts the same three spellings for 4, 6, and 8 seconds.Send Claude's thinking and tool errors back the way Claude sent them.
Minor Changes
Run the server tools of one model turn at the same time.
chat()used to run them one after another, so a turn with several slow tools took as long as all of them together.Every call is still prepared in call order (argument parse, input schema check, approval check,
onBeforeToolCall). Then the server tools start together.onBeforeToolCallruns for every call before any tool of the turn starts.onAfterToolCallfires for each tool when it finishes. The model still gets the results in the order of its calls.If the run aborts before the tools start, no tool starts. Each call gets the error result "Operation aborted".
If a tool or a hook throws, the other tools of the turn finish first. Then the error is thrown.
toolCacheMiddlewareno longer dedupes identical calls in one turn, because they run at the same time.Opt out with
chat({ toolExecution: 'sequential' }).
snapDurationandsnapToDurationOptionaccept seconds (6), a numeric string ("6"), a seconds template ("6s"), or a keyword the model lists ("auto").durationToSecondsreads the numeric forms. Sora (sora-2,sora-2-pro) accepts4 | 8 | 12,"4" | "8" | "12", or"4s" | "8s" | "12s"and sends"4" | "8" | "12". Lovable Veo accepts the same three spellings for 4, 6, and 8 seconds.
Patch Changes
Send Claude's thinking and tool errors back the way Claude sent them.
A tool message with
errornow sendstool_result.is_error: true, so Claude sees that the tool failed.A
redacted_thinkingblock is no longer dropped. It becomes a thinking part withredacted: true, an emptycontent, and the encrypted data insignature. The flag survives the stream, the UI messages, the wire, and stored threads, and the next request sends the block back as{ type: 'redacted_thinking', data }.On the AG-UI wire, a redacted block is its own reasoning message. Its id starts with
redacted_thinking-, and theREASONING_ENCRYPTED_VALUEevent'sentityIdpoints to that id. An AG-UI client keeps message ids, so it sends the block back as redacted data, not as a signature.A thinking block's signature now names its reasoning message in
entityId, not the step. An AG-UI client attaches the signature to that message, so it can send it back.ThinkingPartandModelMessage['thinking']have the new optionalredactedfield.
createMCPServerfrom@tanstack/ai-mcp/server:A resource
read(uri, variables, ctx)now gets the requested URI, the template variables, andctx.context(thehandlecontext plusauthInfo). A template resource can takelist(ctx)forresources/list.metadata._metaon a tool definition is sent as the MCP tool_meta, so a tool can link an MCP Apps view with_meta.ui.resourceUri.New
sessions: 'stateless'serves spec 2025 clients without a session store, so it works on Cloudflare Workers and other multi-instance hosts. It is now the default. In that mode,ctx.context.requestInputthrows a clear error for a spec 2025 client, andctx.context.sampleuses thesampleoption. Setsessions: 'memory'to keep the old spec 2025 sessions.serveMCPStdiostill uses'memory'whensessionsis not set.New
onerroroption receives SDK transport and protocol errors, including theserveMCPStdiotransport errors.Tool and prompt schemas are converted and compiled once at
createMCPServer, not on every request.Output schemas are advertised from the output view, and tool results are parsed with the output schema, so a transform or pipe no longer fails the structured-content check. Async output schemas work. Output that fails its schema returns a tool error that names the tool. An output schema with a bare transform advertises no output schema.
createMCPClient({ server })parses tool output the same way, andreadResource(uri, context)takes a context for the resource.MCPResourceContext,MCPResourceRead, andMCPResourceListare exported.resourceDefinitionacceptslistonly withuriTemplate.
convertSchemaToJsonSchemafrom@tanstack/aitakes a newio: 'input' | 'output'option.
- 0.63.x
0.63.0
27 Sep 2026Add an optional
inputSchematodefineAgent. The agent's tool shows the schema to the model, so the parent model writes the child's input, such as a short brief.rungets the checked input as a typedctx.input. A bad input goes back to the model as a tool error, and the child does not start.inputSchemaneeds tool mode:chat()throws whensubagents.routermeets an agent that has one. Agents withoutinputSchemado not change.Preserve structured outcomes for cancelled and denied tool results. A
tool-resultpart now carriesoutcome: 'cancelled' | 'denied'(newToolResultOutcometype andisToolResultOutcomeguard) across tool execution, streaming, persistence, and UI restoration, so callers can tell a user or middleware decision from an ordinary tool failure without matching error text.statestays'error'for these results. The@tanstack/ai-clientToolResultParttype (used byuseChatmessages) now has the sameoutcomefield.@tanstack/ai-mcpcan create an MCP server withcreateMCPServerandserveMCPStdio. The client package moves from@modelcontextprotocol/sdkto@modelcontextprotocol/clientand@modelcontextprotocol/server. The client tries spec 2026-07-28 first, then the 2025 handshake. When an MCP server asks for input,chat()pauses with anmcp_inputinterrupt. Answer it withresolveInterruptorcancel(), and the tool runs again with the answer inctx.inputResponse. A tool can setexecution: 'task'for a spec 2025 task handle. A tool readsrequestInputandsampleonctx.context, typed byMCPToolContext. The serverauthoption takes the MCP SDKOAuthTokenVerifiershape.jwtVerifierandintrospectionVerifiercover JWT and opaque tokens, and a tool reads the token asctx.context.authInfo.createMCPClient<typeof server>({ transport })types a remote client from acreateMCPServerserver, andcreateMCPClient({ server })calls a server in the same process.server.handle(request, { authInfo, context })takes a token your own middleware verified and values forctx.context, typed withMCPToolContext<{ db: Db }>. A tool'smetadata.titleandmetadata.annotationsreach the host as the MCP title and annotations. A tool with nooutputSchemacan return aCallToolResultas is.sessions: 'reject'turns spec 2025 sessions off for hosts with many instances.
Minor Changes
Add an optional
inputSchematodefineAgent. The agent's tool shows the schema to the model, so the parent model writes the child's input, such as a short brief.rungets the checked input as a typedctx.input. A bad input goes back to the model as a tool error, and the child does not start.inputSchemaneeds tool mode:chat()throws whensubagents.routermeets an agent that has one. Agents withoutinputSchemado not change.Preserve structured outcomes for cancelled and denied tool results. A
tool-resultpart now carriesoutcome: 'cancelled' | 'denied'(newToolResultOutcometype andisToolResultOutcomeguard) across tool execution, streaming, persistence, and UI restoration, so callers can tell a user or middleware decision from an ordinary tool failure without matching error text.statestays'error'for these results. The@tanstack/ai-clientToolResultParttype (used byuseChatmessages) now has the sameoutcomefield.@tanstack/ai-mcpcan create an MCP server withcreateMCPServerandserveMCPStdio. The client package moves from@modelcontextprotocol/sdkto@modelcontextprotocol/clientand@modelcontextprotocol/server. The client tries spec 2026-07-28 first, then the 2025 handshake. When an MCP server asks for input,chat()pauses with anmcp_inputinterrupt. Answer it withresolveInterruptorcancel(), and the tool runs again with the answer inctx.inputResponse. A tool can setexecution: 'task'for a spec 2025 task handle. A tool readsrequestInputandsampleonctx.context, typed byMCPToolContext. The serverauthoption takes the MCP SDKOAuthTokenVerifiershape.jwtVerifierandintrospectionVerifiercover JWT and opaque tokens, and a tool reads the token asctx.context.authInfo.createMCPClient<typeof server>({ transport })types a remote client from acreateMCPServerserver, andcreateMCPClient({ server })calls a server in the same process.server.handle(request, { authInfo, context })takes a token your own middleware verified and values forctx.context, typed withMCPToolContext<{ db: Db }>. A tool'smetadata.titleandmetadata.annotationsreach the host as the MCP title and annotations. A tool with nooutputSchemacan return aCallToolResultas is.sessions: 'reject'turns spec 2025 sessions off for hosts with many instances.
Patch Changes
Preserve failed client tool results across native interrupt resumes and await asynchronous client output validation
Keep a tool-call part at
completewhen its result arrives before the stream ends. If a stream had noTOOL_CALL_END, the end-of-stream safety net set a finished tool-call part back toinput-complete. The final state then depended on async timing.otelMiddleware({ captureContent: true })now records content on media spans (generateImage,generateVideo,generateAudio,generateSpeech,generateTranscription, and the others).gen_ai.input.messagesgets the prompt and input media.gen_ai.output.messagesgets the output URLs or the transcript text. Inline data stays a placeholder.redactandmaxContentLengthapply to each text part.otelMiddlewarewithcaptureContent: truenow keeps multimodal parts structured ingen_ai.input.messages(OTel GenAI part shapes). URL media becomes auripart and provider file handles become afilepart, so traces show what the model looked at. Inline base64 data still records a[image]-style placeholder.
- 0.61.x
0.61.0
24 Sep 2026Add
@tanstack/ai-worldlabswithworldlabsWorld()for Marble world generation throughgenerateWorld(). World Labs jobs return a viewer URL and splat/mesh assets.WorldGenerationResultnow has optionalurl,worldId,operationId, andassetsso job adapters can omit a live-session token.Keep Anthropic's signed thinking order when a provider-executed tool (web_search / web_fetch) runs inside the same response as thinking, and stop provider-executed calls from being classified as client tool interrupts.
Without this, the turn after such a response fails with Anthropic's
thinking or redacted_thinking blocks in the latest assistant message cannot be modified.Forward summarize maxLength through OpenAI-compatible adapters using the token-limit key for their API, regardless of the summarize wrapper name. Keep explicit caller limits unchanged.
Minor Changes
Add
@tanstack/ai-worldlabswithworldlabsWorld()for Marble world generation throughgenerateWorld(). World Labs jobs return a viewer URL and splat/mesh assets.WorldGenerationResultnow has optionalurl,worldId,operationId, andassetsso job adapters can omit a live-session token.
Patch Changes
Keep Anthropic's signed thinking order when a provider-executed tool (web_search / web_fetch) runs inside the same response as thinking, and stop provider-executed calls from being classified as client tool interrupts.
uiMessagesToWirenow splits an assistant message into ordered segments at every thinking part that follows a provider-executed tool call (the rulebuildAssistantMessagesalready applies), instead of emitting allreasoningmessages first and one anchor with the joined text and every tool call. Later anchors get${id}-segment-${n}ids.The run loop records the iteration's thinking, text and tool calls in arrival order and writes one assistant
ModelMessageper segment, so the interruptMESSAGES_SNAPSHOTand the server-side continuation history keep the order too.getBoundaryActionableToolRequestsandexecuteToolCallsskip provider-executed calls, so a run that mixes web search with a client tool no longer parks on "Client tool web_search is ready to run" interrupts.A
MESSAGES_SNAPSHOTandmodelMessagesToUIMessagesfold${id}-segment-${n}messages back into their parent, so the UI still shows one assistant message per response.
Without this, the turn after such a response fails with Anthropic's
thinking or redacted_thinking blocks in the latest assistant message cannot be modified.Forward summarize maxLength through OpenAI-compatible adapters using the token-limit key for their API, regardless of the summarize wrapper name. Keep explicit caller limits unchanged.
Keep a failed tool call's error on the tool message
chat()adds to its message history. Persisted threads now restore the call withstate: 'error'instead of'complete'.Stop a mid-stream resume from duplicating the reasoning block. A hydrated message has no
stepIdon its thinking part — the stored form has nowhere to keep one — so the reasoning replayed on rejoin, which is keyed bystepId, matched nothing and was appended, leaving the turn as thinking, text, thinking.updateThinkingPartnow falls back to the first thinking part that has nostepIdand adopts it, carrying its signature over so the provider's encrypted reasoning is not lost. Parts that already belong to another step are never adopted, so separate reasoning steps still get separate parts.Structured output now reports a response that was cut off at the output cap (
finish_reason: "length") as a truncation error instead of a JSON parse error or a "no content" / "missing structured result" error. This coverschat({ outputSchema })in native combined mode (error codemax_tokens),structuredOutputStream()inopenai-baseandai-openrouter(RUN_ERRORwith codemax_tokens), and their non-streamstructuredOutput(). A truncated document used to read as a schema failure; the error now says the token limit was reached.
- 0.59.x
0.59.0
24 Sep 2026Add first-class subagents.
chat({ subagents })starts named child agents (router spawn, or a synthetic tool when there is no router). The stream emits AG-UISUBAGENT_*events withsubagentRunId. The client stores nestedtype: 'subagent'parts.useChat().subagentsandpart.subagentare the same live handle, includingstop().In the React chat UI kit, pass the same agents to
options.subagentsthat you pass tochat(). A subagent card can style its own child's parts:<Parts partsComponents={...} toolsComponents={...} />. Each entry replaces the root entry of the same key for that card and its nested children. Keys you do not set use the root widgets. The card's tool names, toolinputandoutput, and approvals are typed from that agent'stools. The rootinterruptsComponentsalso accepts the children's approval tools andinterrupts.Pass the same
defineAgentlist touseChat({ subagents })when you are not using the chat UI factory.part.subagent.namenarrows to those names, and that child's message parts use the agent's tools.A child card keeps all of the child's work: text, reasoning, tool calls, tool results, approvals, and nested children. A child can stop for an approval or a client tool. Its
SUBAGENT_FINISHEDhasoutcome: { type: 'suspended' }, and the parent run ends with that interrupt. The resume continues the same child. PassparentRunId: ctx.parentRunIdandresume: ctx.resumeto the childchat().The AI devtools Conversation tab shows each subagent as a card of steps, drawn like the parent's steps. The steps are the child's server iterations when server events reach the devtools, else they come from the browser messages. The User view shows the child's text and tool outputs. Nested children show the same way. The child's card updates while it streams, and a later turn keeps the earlier turns.
Child token usage is added to the parent
RUN_FINISHED.usage. Child messages travel on the AG-UI wire as their own messages, tagged withsubagentRunId.@tanstack/ainow depends on@ag-ui/core1.0.0. Subagent events come from that package.AG-UI
{ type: 'file' }content sources now cross the wire asContentPartFileSource. No adapter reads them yet, sochat()throws before it calls the adapter.RUN_FINISHED.usagenow carriescacheWriteInputTokens.metadata.tanstack.usagestill carriespromptTokensDetails.cacheWriteTokens, so older readers see the same usage as before.chat({ subagentRunId })puts that id on the middleware context asctx.subagentRunId. A childchat()passessubagentRunId: ctx.subagentRunId, so a middleware inside the child knows it runs as a subagent and which card it belongs to. The field is absent on a top-level run.fromSpecTokenUsageadds every entry ofRUN_FINISHED.usage. Before, it read only the first entry. A run with more than one usage entry now reports the total.Carry the failing subagent's own error message and code on the parent
RUN_ERRORinstead of a generic "A subagent failed", and keep the children's token usage on that terminal so a failed turn still reports what it spent. Also settle the routed-subagent persistence record when a run is stopped while the router is still deciding, which previously left the recordrunningforever and madereconstructChathand the client a run to tail that never emits.A subagent run record stores
parentRunId,subagentRunId, andname. Each child keeps its full transcript: text, reasoning, tool calls, and tool results.reconstructChatuseslistByParentRunto put each child card back, with nested children and a child that waits for an approval. A later message keeps that link, so the next agent still sees the child text.Subagent support in a store is optional.
runPersistenceConformancechecks the three link fields andlistByParentRunonly when the store haslistByParentRun. A store without it passes with no change and noskipMethodsentry.
- 0.58.x
0.58.0
21 Sep 2026Flush CUSTOM events through the durability layer as soon as they are emitted, so progress events such as
compaction:startedreach the client at emit time. High-volume events (process.stdout,process.stderr,sandbox.file,sandbox.file.diff) still batch. Pass{ batch: true }onemitCustomEventto opt an event into the batch.
- 0.57.x
0.57.0
21 Sep 2026Add decide() and TypeSafe Jev evaluate adapters.
Callers await one decide({ adapter, state, questions }) call.
Questions use choice(), score(), and boolean(). Answers sit on the result (value, probability, confidence). Usage sits on result.meta.Jev transports:
Add a
generateVoice()activity for creating a voice, and refresh the ElevenLabs model lists.generateVoice()creates a reusable voice and returns voice ids thatgenerateSpeech({ voice })accepts. Providers create voices two ways, and the activity covers both: design one from a textprompt, or derive one fromreferenceAudio. Pass anameto keep the voice in the provider's library, and readsavedon each returned voice to see what happened. Every returned voice also carriesstatus, which is'ready'on every adapter today. Comes withVoiceAdapter/BaseVoiceAdapterfor adapter authors, thevoice:request:*andvoice:usagedevtools events, and avoice_generationOpenTelemetry operation name. See Voice Creation.listVoices()reads an account voice catalog back, for when you did not store the idgenerateVoice()returned. It filters byorigin(premade,generated,cloned,professional).listVoicesis an optional method onTTSAdapterrather thanVoiceAdapter, becausevoiceis agenerateSpeech()option and that is where the id gets used. Only providers with a per-account catalog implement it; where the voice list is fixed the package publishes it directly (GeminiTTSVoicesfrom@tanstack/ai-gemini, theOpenAITTSVoiceunion from@tanstack/ai-openai), which beats a network call, and callinglistVoices()on such an adapter throws and points at those exports.elevenlabsSpeechimplements it againstGET /v1/voices.elevenlabsVoiceDesign()implements voice creation oneleven_ttv_v3andeleven_multilingual_ttv_v2. ElevenLabs' design-then-create two-step is hidden inside the adapter. Onlyeleven_ttv_v3acceptsreferenceAudio, and it is a design reference rather than a straight clone: apromptis still required, andmodelOptions.promptStrengthbalances the description against the recording.ElevenLabs models.
@elevenlabs/elevenlabs-jsmoves to^2.68.0and@elevenlabs/clientto^1.25.0. The bump is what makes the new music and transcription ids type-check: 2.44 pinned the music request tomodelId?: 'music_v1'and typed speech-to-text as a two-member'scribe_v2' | 'scribe_v1'union. Addseleven_v3_conversational(TTS),scribe_v2_medical(Scribe), andmusic_v2_5andmusic_v2(music).eleven_turbo_v2,eleven_turbo_v2_5,eleven_monolingual_v1,scribe_v1, andmusic_v1are still accepted and are commented as deprecated inmodel-meta.ts.ELEVENLABS_AUDIO_MODELSis now pinned to the SDK's ownMusicModelId | SfxModelIdwithsatisfies, so an id the SDK drops fails the build instead of the request.Breaking:
eleven_text_to_sound_v1is removed fromELEVENLABS_AUDIO_MODELS. ElevenLabs dropped it from the catalog and from the SDK'sSfxModelId, so the adapter can no longer send it. Useeleven_text_to_sound_v2.
Minor Changes
Add decide() and TypeSafe Jev evaluate adapters.
Callers await one decide({ adapter, state, questions }) call.
Questions use choice(), score(), and boolean(). Answers sit on the result (value, probability, confidence). Usage sits on result.meta.Jev transports:
@tanstack/ai-typesafe (typesafeDecider)
@tanstack/ai-openrouter (openRouterDecider)
@tanstack/ai-vercel-gateway (vercelGatewayDecider)
@tanstack/ai-cloudflare (cloudflareDecider)
Add a
generateVoice()activity for creating a voice, and refresh the ElevenLabs model lists.generateVoice()creates a reusable voice and returns voice ids thatgenerateSpeech({ voice })accepts. Providers create voices two ways, and the activity covers both: design one from a textprompt, or derive one fromreferenceAudio. Pass anameto keep the voice in the provider's library, and readsavedon each returned voice to see what happened. Every returned voice also carriesstatus, which is'ready'on every adapter today. Comes withVoiceAdapter/BaseVoiceAdapterfor adapter authors, thevoice:request:*andvoice:usagedevtools events, and avoice_generationOpenTelemetry operation name. See Voice Creation.listVoices()reads an account voice catalog back, for when you did not store the idgenerateVoice()returned. It filters byorigin(premade,generated,cloned,professional).listVoicesis an optional method onTTSAdapterrather thanVoiceAdapter, becausevoiceis agenerateSpeech()option and that is where the id gets used. Only providers with a per-account catalog implement it; where the voice list is fixed the package publishes it directly (GeminiTTSVoicesfrom@tanstack/ai-gemini, theOpenAITTSVoiceunion from@tanstack/ai-openai), which beats a network call, and callinglistVoices()on such an adapter throws and points at those exports.elevenlabsSpeechimplements it againstGET /v1/voices.elevenlabsVoiceDesign()implements voice creation oneleven_ttv_v3andeleven_multilingual_ttv_v2. ElevenLabs' design-then-create two-step is hidden inside the adapter. Onlyeleven_ttv_v3acceptsreferenceAudio, and it is a design reference rather than a straight clone: apromptis still required, andmodelOptions.promptStrengthbalances the description against the recording.ElevenLabs models.
@elevenlabs/elevenlabs-jsmoves to^2.68.0and@elevenlabs/clientto^1.25.0. The bump is what makes the new music and transcription ids type-check: 2.44 pinned the music request tomodelId?: 'music_v1'and typed speech-to-text as a two-member'scribe_v2' | 'scribe_v1'union. Addseleven_v3_conversational(TTS),scribe_v2_medical(Scribe), andmusic_v2_5andmusic_v2(music).eleven_turbo_v2,eleven_turbo_v2_5,eleven_monolingual_v1,scribe_v1, andmusic_v1are still accepted and are commented as deprecated inmodel-meta.ts.ELEVENLABS_AUDIO_MODELSis now pinned to the SDK's ownMusicModelId | SfxModelIdwithsatisfies, so an id the SDK drops fails the build instead of the request.Breaking:
eleven_text_to_sound_v1is removed fromELEVENLABS_AUDIO_MODELS. ElevenLabs dropped it from the catalog and from the SDK'sSfxModelId, so the adapter can no longer send it. Useeleven_text_to_sound_v2.
- 0.55.x
0.55.0
17 Sep 2026Page long chat threads on hydrate. Pass
history: { pageSize }withpersistence: true. Then callloadOlderMessages()to prepend older turns.withPersistencemerges incoming messages by id so a short client list keeps stored extras.loadThreadaccepts optionallimit/beforeand can return aMessagePage.chat: keep ui-resource parts emitted during the current run on the interrupt MESSAGES_SNAPSHOT. Server tools emitting
ui://widgets viactx.emitCustomEvent('ui-resource', ...)now have the resource recorded on the tool-call anchor ModelMessage, so the MESSAGES_SNAPSHOT emitted when the run pauses on a client tool no longer drops the widget from client state (#1397).
- 0.54.x
0.54.0
10 Sep 2026Add
generateWorld()andgenerateLiveVideo()for prompt-steerable sessions, plus a first-party Reactor adapter (reactorWorld,reactorVideo) and falfalLiveVideo()for H3 Max Director. Reactor returns a session JWT. falLiveVideo returns the WMA app id onresult.modelso the browser can callwma(live.model).generateVideo()stays the job path that polls for a file URL.Emit the native structured result before RUN_FINISHED so clients receive the final object before the run closes. Emit only RUN_ERROR if parsing fails.
Wait for the active subscriber to process all events before resolving send, including streams that take more than 32 timer ticks to process.
Minor Changes
Add
generateWorld()andgenerateLiveVideo()for prompt-steerable sessions, plus a first-party Reactor adapter (reactorWorld,reactorVideo) and falfalLiveVideo()for H3 Max Director. Reactor returns a session JWT. falLiveVideo returns the WMA app id onresult.modelso the browser can callwma(live.model).generateVideo()stays the job path that polls for a file URL.
Patch Changes
Emit the native structured result before RUN_FINISHED so clients receive the final object before the run closes. Emit only RUN_ERROR if parsing fails.
Wait for the active subscriber to process all events before resolving send, including streams that take more than 32 timer ticks to process.
- 0.53.x
0.53.0
3 Sep 2026Add
@tanstack/ai-cloudflare: a Cloudflare adapter for Workers AI chat, summarization, embeddings, image generation, text-to-speech, and transcription over theenv.AIbinding or the REST API, with AI Gateway routing (gatewayoption andcloudflareGateway()helper for other providers).@tanstack/ailearns thecloudflaremax-tokens key for summarize, letsdefineByokProviderdeclare companion credentials withwith, and addsgetByokKeys(request, { name: provider })to@tanstack/ai/byok/server.@tanstack/ai-client'sdefineByoktakesproviders: a send for a provider with companions (Cloudflare token plus account id) carries everyx-byok-*header and prompts for each missing value.
- 0.52.x
0.52.3
3 Sep 2026Replay OpenAI Responses reasoning items with function_call on the next tool turn. Default
include: ['reasoning.encrypted_content']only on reasoning models.
0.52.2
2 Sep 2026createChatOptionsnow preserves the runtime-context requirement on its return type. When tools or middleware declare a required context, the input already enforcescontextviaRuntimeContextOption, but the return type declared it optional — so spreading the result intochat()failed to typecheck (Type 'undefined' is not assignable to type '...'). The return type now applies the sameRuntimeContextOptionconditional as the parameter, so the documented spread pattern compiles with context-typed tools. Type-level only; runtime is unchanged.
0.52.1
1 Sep 2026Keep the first text delta when a message speaks after a tool call and the text
arrives in more than one chunk.
0.52.0
28 Aug 2026Keep canonical chat history separate from compacted provider context. Reuse
validated compaction checkpoints through an optional persistence metadata store.Add
ctx.emitCustomEventon chat middleware context. The engine yieldsCUSTOMchunks while hooks such asonConfigare still running, so a long
middleware step can send progress before it finishes. Compaction uses this
to emitcompaction:startedbefore the strategy returns.Preserve draft-07 tuple
itemsarrays during structured-output conversion.Keep a single widening map for homogeneous arrays so
undoNullWideningapplies it to every element.
Minor Changes
Keep canonical chat history separate from compacted provider context. Reuse
validated compaction checkpoints through an optional persistence metadata store.Add
ctx.emitCustomEventon chat middleware context. The engine yieldsCUSTOMchunks while hooks such asonConfigare still running, so a long
middleware step can send progress before it finishes. Compaction uses this
to emitcompaction:startedbefore the strategy returns.
Patch Changes
Preserve draft-07 tuple
itemsarrays during structured-output conversion.Keep a single widening map for homogeneous arrays so
undoNullWideningapplies it to every element.
- 0.51.x
0.51.0
27 Aug 2026Add
@tanstack/ai-skills: portable Agent Skills (SKILL.md) as a first-classchat()middleware.withSkills(sources, options?)renders a skill catalog and aload_skilltool so any tool-calling model can load skills on demand, on any provider, with no server sandbox. Skills come frominlineSkill,skillDirectory(/node), or a build-timestaticSkillsbundle, and compose viaaggregate/dedupe/filter/cache.createResourceToolexposes a skill's bundled files throughread_skill_resource, andrunSkillSourceConformance(/testing) validates customSkillSourceadapters. The catalog renders as<available_skills>XML for Anthropic models and markdown for others; portable and hosted (native) skills refuse to combine in one call.Core
@tanstack/ainow exportsSkillLimitError. The native factories throw it (or add validation):codeExecutionTool(@tanstack/ai-anthropic) frames its 8-skill cap, andshellTool(@tanstack/openai-base) now validatesskill_idformat instead of nothing.@tanstack/ai-sandboxreuses the shared skill-directory walk from@tanstack/ai-skills.withSkillssends askills:stateCUSTOM chunk so TanStack AI DevTools can show the catalog and which skills the model loaded.Keep
append()pending until the HTTP response is fully processed, including laterRUN_FINISHEDevents in the same agent loop.Fix
StreamProcessordropping the firstTEXT_MESSAGE_CONTENTdelta when aTOOL_CALL_STARTevent'sparentMessageIdprecedes that message'sTEXT_MESSAGE_START— the normal AG-UI shape for "call a tool, then explain the result" as one assistant turn (#1247).
Minor Changes
Add
@tanstack/ai-skills: portable Agent Skills (SKILL.md) as a first-classchat()middleware.withSkills(sources, options?)renders a skill catalog and aload_skilltool so any tool-calling model can load skills on demand, on any provider, with no server sandbox. Skills come frominlineSkill,skillDirectory(/node), or a build-timestaticSkillsbundle, and compose viaaggregate/dedupe/filter/cache.createResourceToolexposes a skill's bundled files throughread_skill_resource, andrunSkillSourceConformance(/testing) validates customSkillSourceadapters. The catalog renders as<available_skills>XML for Anthropic models and markdown for others; portable and hosted (native) skills refuse to combine in one call.Core
@tanstack/ainow exportsSkillLimitError. The native factories throw it (or add validation):codeExecutionTool(@tanstack/ai-anthropic) frames its 8-skill cap, andshellTool(@tanstack/openai-base) now validatesskill_idformat instead of nothing.@tanstack/ai-sandboxreuses the shared skill-directory walk from@tanstack/ai-skills.withSkillssends askills:stateCUSTOM chunk so TanStack AI DevTools can show the catalog and which skills the model loaded.
Patch Changes
Keep
append()pending until the HTTP response is fully processed, including laterRUN_FINISHEDevents in the same agent loop.Fix
StreamProcessordropping the firstTEXT_MESSAGE_CONTENTdelta when aTOOL_CALL_STARTevent'sparentMessageIdprecedes that message'sTEXT_MESSAGE_START— the normal AG-UI shape for "call a tool, then explain the result" as one assistant turn (#1247).
- 0.50.x
0.50.0
27 Aug 2026Preserve tool-result identity, metadata, multimodal content, and timestamps across message conversions and hydration.
Correct the public
WireMessagetype. System and user messages now require content, and the union no longer includes outbound activity messages.Keep structured multimodal content compatible with the AI devtools message store.
Preserve AG-UI message fields and UI state across serialization, snapshots, and persistence.
Minor Changes
Preserve tool-result identity, metadata, multimodal content, and timestamps across message conversions and hydration.
Correct the public
WireMessagetype. System and user messages now require content, and the union no longer includes outbound activity messages.Keep structured multimodal content compatible with the AI devtools message store.
Patch Changes
Preserve AG-UI message fields and UI state across serialization, snapshots, and persistence.
- 0.49.x
0.49.1
24 Aug 2026Allow cancelling a tool interrupt when its runtime tool is unavailable or its schema has changed
- 0.48.x
0.48.0
22 Aug 2026Put AG-UI extras under
metadata.tanstack. SSE/HTTP wire events are spec-only.sendMessage({ content, metadata })stamps user metadata on the user message.
In-processchat()still yieldstoolName,TOOL_CALL_END.input, and TanStackTokenUsage.
Thinking signatures round-trip onREASONING_ENCRYPTED_VALUE.
Wire messages usecontent/toolCalls/ fan-out roles, notparts.
Minor Changes
Put AG-UI extras under
metadata.tanstack. SSE/HTTP wire events are spec-only.sendMessage({ content, metadata })stamps user metadata on the user message.
In-processchat()still yieldstoolName,TOOL_CALL_END.input, and TanStackTokenUsage.
Thinking signatures round-trip onREASONING_ENCRYPTED_VALUE.
Wire messages usecontent/toolCalls/ fan-out roles, notparts.
- 0.47.x
0.47.3
21 Aug 2026Reject non-streaming text calls when their event stream ends with a
RUN_ERROR.Preserve signed thinking blocks relative to tool calls when converting assistant UI messages. A thinking block that follows a provider-executed tool now starts a new assistant segment instead of being replayed before that tool.
Fix tool-call
inputcorruption when aTEXT_MESSAGE_CONTENTevent arrives betweenTOOL_CALL_ARGSevents. Text events no longer force-complete in-flight tool calls, andinputis only set when a strictJSON.parseof the accumulated arguments succeeds.
Patch Changes
Reject non-streaming text calls when their event stream ends with a
RUN_ERROR.Preserve signed thinking blocks relative to tool calls when converting assistant UI messages. A thinking block that follows a provider-executed tool now starts a new assistant segment instead of being replayed before that tool.
Fix tool-call
inputcorruption when aTEXT_MESSAGE_CONTENTevent arrives betweenTOOL_CALL_ARGSevents. Text events no longer force-complete in-flight tool calls, andinputis only set when a strictJSON.parseof the accumulated arguments succeeds.New provider adapter:
@tanstack/ai-llmgatewayconnects TanStack AI to
LLM Gateway, an open-source, self-hostable AI
gateway that routes one OpenAI-compatible endpoint to hundreds of models
across many providers.llmGatewayText/createLLMGatewayText— streaming chat with tool
calling, structured outputs, multimodal (image) input, and reasoning
deltas (reasoning_content) surfaced as AG-UIREASONING_*eventsllmGatewaySummarize/createLLMGatewaySummarize— summarization via
the sharedChatStreamSummarizeAdapterLLMGATEWAY_CHAT_MODELS— a curated list of flagship model ids, with
per-model input modalities and tool capabilities resolved at the type
level, and any other model id from llmgateway.io/models accepted and
typed against the generic provider optionsprovider/modelids pin routing to a specific provider; bare ids let
the gateway choose
@tanstack/airegistersllmgatewayin the summarize wrapper's
provider-native token-key map, sosummarize({ maxLength })reaches the
gateway asmax_tokensinstead of being dropped with a warning.
0.47.2
20 Aug 2026Route adapter-emitted
RUN_ERRORevents through middlewareonErrorhooks and preserve provider error codes in persisted run failures.
0.47.1
20 Aug 2026Persist completed structured outputs as structured-output message parts and restore them during chat hydration.
- 0.46.x
0.46.0
19 Aug 2026Add a self-describing
billedfield toTokenUsageso billed quantities carry the unit they are counted in (#816).usage.billedis{ quantity, unit }with aBillingUnitunion ('seconds','units','images','tokens', ... open-ended). The deprecatedunitsBilled/durationSecondscounts are still populated for backward compatibility. The fal adapters report{ quantity, unit: 'units' }, Grok video{ quantity, unit: 'seconds' }, the OpenAI/Grok/BytePlus duration-billed transcription paths{ quantity, unit: 'seconds' }, BytePlus Seedream images{ quantity, unit: 'images' }, BytePlus Seedance video{ quantity, unit: 'tokens' }, and Cohere/OpenRouter rerank{ quantity, unit: 'units' }(search units). Persistence sumsbilledwhen both reports use the same unit.otelMiddlewareemits the pair astanstack.ai.usage.billed_quantity/tanstack.ai.usage.billed_unitspan attributes.WebSocket transport: a full-duplex, resumable third transport alongside SSE and
NDJSON, reusing the same delivery-durability seam.On the server,
@tanstack/aiaddstoWebSocketStream(socket, request, { onRun, durability, batch, heartbeatMs, idleTimeoutMs, debug })— a portable core that
pumps a conversation over an already-accepted WHATWGWebSocketLikeserver
socket (Node viaws, Bun, etc.), andtoWebSocketResponse(request, { onRun, … }), a thin wrapper that upgrades viaWebSocketPairand returns a 101
Responseon Cloudflare Workers/Durable Objects (it throws elsewhere, pointing
you attoWebSocketStream). Because one socket outlives manychat()turns
(client-tool resubmits, follow-up user messages), you pass anonRun(ctx) => AsyncIterable<StreamChunk>factory instead of a prebuilt stream — the helper
calls it per inboundRunAgentInputframe. The socket is conversation-scoped:
it stays open across turns and closes on client close or the idle timeout
(which never fires while a turn is still streaming), with a periodic
{ type: 'ping' }heartbeat. An{ type: 'abort', runId }control frame
aborts only that turn, leaving the socket open. A turn that fails is surfaced
to the client as a liveRUN_ERRORframe, mirroring the HTTP transports. Durability is keyed per turn and reuses
the existingdurableStreamSource, so server→client frames carry the same
{ id, chunk }envelope as NDJSON.resumeWebSocketStream(socket, { adapter })
andresumeWebSocketResponse({ adapter })replay a run read-only from the
durability log (no model call).On the client,
webSocket(url, options)(in@tanstack/ai-client, re-exported
from@tanstack/ai-react,-solid,-vue,-svelte, and-angular) is a
full-duplexsubscribe+sendconnection adapter foruseChat.send()
writes aRunAgentInputframe;subscribe()yields inbound chunks, ignores
heartbeats, unwraps durable envelopes, and auto-reconnects a dropped durable run
by reopening with?runId=&offset=(browsers can't set aLast-Event-ID
handshake header, so the offset rides in the URL). The reconnect bookkeeping
(offset de-dupe, no-progress ceiling →StreamReconnectLimitError) is shared
with the HTTP adapters via the newcreateReconnectTracker, and a fatal drop
surfaces to the consumer (StreamReadError/StreamReconnectLimitError)
instead of hanging. Aborting a run (stop()inuseChat) sends the
{ type: 'abort', runId }frame so the server cancels the turn instead of
generating to completion, andjoinRun()opens its own replay socket so a
rejoin never collides with the live conversation socket.Preserve reasoning in server chat message history and interrupt snapshots.
Minor Changes
Add a self-describing
billedfield toTokenUsageso billed quantities carry the unit they are counted in (#816).usage.billedis{ quantity, unit }with aBillingUnitunion ('seconds','units','images','tokens', ... open-ended). The deprecatedunitsBilled/durationSecondscounts are still populated for backward compatibility. The fal adapters report{ quantity, unit: 'units' }, Grok video{ quantity, unit: 'seconds' }, the OpenAI/Grok/BytePlus duration-billed transcription paths{ quantity, unit: 'seconds' }, BytePlus Seedream images{ quantity, unit: 'images' }, BytePlus Seedance video{ quantity, unit: 'tokens' }, and Cohere/OpenRouter rerank{ quantity, unit: 'units' }(search units). Persistence sumsbilledwhen both reports use the same unit.otelMiddlewareemits the pair astanstack.ai.usage.billed_quantity/tanstack.ai.usage.billed_unitspan attributes.WebSocket transport: a full-duplex, resumable third transport alongside SSE and
NDJSON, reusing the same delivery-durability seam.On the server,
@tanstack/aiaddstoWebSocketStream(socket, request, { onRun, durability, batch, heartbeatMs, idleTimeoutMs, debug })— a portable core that
pumps a conversation over an already-accepted WHATWGWebSocketLikeserver
socket (Node viaws, Bun, etc.), andtoWebSocketResponse(request, { onRun, … }), a thin wrapper that upgrades viaWebSocketPairand returns a 101
Responseon Cloudflare Workers/Durable Objects (it throws elsewhere, pointing
you attoWebSocketStream). Because one socket outlives manychat()turns
(client-tool resubmits, follow-up user messages), you pass anonRun(ctx) => AsyncIterable<StreamChunk>factory instead of a prebuilt stream — the helper
calls it per inboundRunAgentInputframe. The socket is conversation-scoped:
it stays open across turns and closes on client close or the idle timeout
(which never fires while a turn is still streaming), with a periodic
{ type: 'ping' }heartbeat. An{ type: 'abort', runId }control frame
aborts only that turn, leaving the socket open. A turn that fails is surfaced
to the client as a liveRUN_ERRORframe, mirroring the HTTP transports. Durability is keyed per turn and reuses
the existingdurableStreamSource, so server→client frames carry the same
{ id, chunk }envelope as NDJSON.resumeWebSocketStream(socket, { adapter })
andresumeWebSocketResponse({ adapter })replay a run read-only from the
durability log (no model call).On the client,
webSocket(url, options)(in@tanstack/ai-client, re-exported
from@tanstack/ai-react,-solid,-vue,-svelte, and-angular) is a
full-duplexsubscribe+sendconnection adapter foruseChat.send()
writes aRunAgentInputframe;subscribe()yields inbound chunks, ignores
heartbeats, unwraps durable envelopes, and auto-reconnects a dropped durable run
by reopening with?runId=&offset=(browsers can't set aLast-Event-ID
handshake header, so the offset rides in the URL). The reconnect bookkeeping
(offset de-dupe, no-progress ceiling →StreamReconnectLimitError) is shared
with the HTTP adapters via the newcreateReconnectTracker, and a fatal drop
surfaces to the consumer (StreamReadError/StreamReconnectLimitError)
instead of hanging. Aborting a run (stop()inuseChat) sends the
{ type: 'abort', runId }frame so the server cancels the turn instead of
generating to completion, andjoinRun()opens its own replay socket so a
rejoin never collides with the live conversation socket.
Patch Changes
Preserve reasoning in server chat message history and interrupt snapshots.
Classify Anthropic, Gemini, and OpenAI native tools with stable runtime discriminators so ordinary functions can use the same public names without selecting provider-native behavior. Native tools must come from the adapter factory (
webSearchTool(),googleSearchTool(), and the rest). A reservednamealone does not select a native converter.chat()throwsDuplicateToolNameErrorwhen a factory tool and a custom function share the same public name.Previously the converters picked provider-native behavior by
tool.name. Tool names are public application identifiers, so a plain function calledweb_search,google_search, orcode_executionwas routed into a native converter: it lost itsinputSchemaand was sent as a provider-only payload (and on Anthropic could also flip oncode_execution/ skills beta headers). Native tools are now identified by adapter-owned metadata, which converters strip before building the wire payload, so provider API versions stay confined to the wire converters.Also preserves Anthropic
webSearchTooloptions (max_uses,allowed_domains,blocked_domains,user_location,cache_control) on the wire payload.Also fixes
googleSearchTool({ searchTypes: … })being silently dropped on the experimentalgeminiTextInteractions()adapter. The Interactions converter read a snake_casesearch_typesarray, but the public factory takes the Generate Content shape (GoogleSearch.searchTypes: { webSearch?, imageSearch? }), so the field never matched and every request fell back to the provider default of web-search-only. The camelCase config is now translated to the Interactions wire list.
- 0.45.x
0.45.1
18 Aug 2026Return recoverable Groq
tool_use_failedresponses to the model as non-executable tool errors so the agent loop can repair them.
0.45.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.Rename Code Mode "skills" to "snippets" to disambiguate them from agent skills (the
SKILL.mdpackaging system).Breaking — package rename.
@tanstack/ai-code-mode-skillsis now published as@tanstack/ai-code-mode-snippets. Update your dependency and imports. The/storagesubpath is unchanged.Breaking — API rename. Every
Skill/skillidentifier in the package public API becomesSnippet/snippet, for example:Breaking — sandbox hook (
@tanstack/ai-code-mode). ThecreateCodeModeToolconfig optiongetSkillBindingsis renamed togetSnippetBindings(same signature — an optional() => Promise<Record<string, ToolBinding>>returning dynamic bindings merged at execution time).Breaking — wire contract (
@tanstack/ai). The Code Mode custom events are renamed:code_mode:skill_call/_result/_error→code_mode:snippet_*(payload fieldskill→snippet), andskill:registered→snippet:registered. The exported event typesCodeModeSkillCallEvent/CodeModeSkillResultEvent/CodeModeSkillErrorEvent/SkillRegisteredEventare renamed to theirSnippetequivalents.Preserve existing message IDs on interrupt
MESSAGES_SNAPSHOTevents.
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.Rename Code Mode "skills" to "snippets" to disambiguate them from agent skills (the
SKILL.mdpackaging system).Breaking — package rename.
@tanstack/ai-code-mode-skillsis now published as@tanstack/ai-code-mode-snippets. Update your dependency and imports. The/storagesubpath is unchanged.Breaking — API rename. Every
Skill/skillidentifier in the package public API becomesSnippet/snippet, for example:codeModeWithSkills()→codeModeWithSnippets()skillsToTools()/skillToTool()→snippetsToTools()/snippetToTool()skillsToBindings()/skillsToSimpleBindings()→snippetsToBindings()/snippetsToSimpleBindings()selectRelevantSkills()→selectRelevantSnippets()createSkillManagementTools()→createSnippetManagementTools()createSkillsSystemPrompt()→createSnippetsSystemPrompt()generateSkillTypes()→generateSnippetTypes()createFileSkillStorage()/createMemorySkillStorage()→createFileSnippetStorage()/createMemorySnippetStorage()Types:
Skill,SkillStorage,SkillIndexEntry,SkillStats,SkillBinding,SkillsConfig,CodeModeWithSkillsOptions/Result→ theSnippet…equivalentsOptions:
skills→snippets,skillsAsTools→snippetsAsTools,maxSkillsInContext→maxSnippetsInContextRuntime tools:
search_skills/get_skill/register_skill→search_snippets/get_snippet/register_snippetSandbox bindings are now exposed with the
snippet_prefix (wasskill_)
Breaking — sandbox hook (
@tanstack/ai-code-mode). ThecreateCodeModeToolconfig optiongetSkillBindingsis renamed togetSnippetBindings(same signature — an optional() => Promise<Record<string, ToolBinding>>returning dynamic bindings merged at execution time).Breaking — wire contract (
@tanstack/ai). The Code Mode custom events are renamed:code_mode:skill_call/_result/_error→code_mode:snippet_*(payload fieldskill→snippet), andskill:registered→snippet:registered. The exported event typesCodeModeSkillCallEvent/CodeModeSkillResultEvent/CodeModeSkillErrorEvent/SkillRegisteredEventare renamed to theirSnippetequivalents.
Patch Changes
Preserve existing message IDs on interrupt
MESSAGES_SNAPSHOTevents.Timestamp native and fallback structured-output events when they are emitted so their lifecycle remains chronologically ordered.
Return malformed tool arguments to the model as an error result so the agent loop can repair them.
- 0.44.x
0.44.1
14 Aug 2026Preserve stable IDs and creation timestamps for server-generated assistant messages across persistence and hydration.
0.44.0
10 Aug 2026ai: add
timeoutandabortSignalto media generation activitiesMedia activities (
generateImage,generateAudio,generateVideo,generateSpeech,generateTranscription, andsummarize) now accept optionaltimeoutandabortSignal. Core composes them into a request-specific effective signal, races the adapter call so hung providers reject, clears timeout resources on settle, and routes aborts to middlewareonAbort(notonError).@tanstack/ai-falforwards the signal tofal.subscribe()/fal.queue.submit()per request — never via globalfal.config()— so concurrent generations stay isolated.Add a multimodal
embed()activity. A single primitive covers one input or a batch —inputaccepts a string, a text part, an image part, or a fused text+image item written as a nestedArray<ContentPart>([textPart, imagePart], the same shape chat messages use), one vector per item, with the accepted item types narrowed per model at compile time. Top-leveldimensionsrequests Matryoshka output sizes where supported. Results carryembeddings: [{ vector, index }]plususagewhen the provider reports it, andembed()participates in generation middleware, debug logging, OTel (gen_ai.operation.name: embeddings), and devtools events like every other activity.Provider adapters:
openaiEmbedding(text-embedding-3-small/large),geminiEmbedding(gemini-embedding-001),mistralEmbedding(mistral-embed, codestral-embed),ollamaEmbedding(nomic-embed-text and any local model),bedrockEmbedding(Titan Text V2, Titan Multimodal G1 with fused text+image, Cohere Embed v3 on Bedrock), and@tanstack/ai-cohere'scohereEmbedding(embed-v4.0, multimodal text+image with requiredinputType).add
rerank()activity for reordering documents by relevance to a queryAdds a provider-agnostic
rerank()activity (withcreateRerankOptions, theRerankAdapterinterface, andBaseRerankAdapter). Documents may be strings or JSON-serializable objects — object documents are serialized for the provider and the original element is returned in the result, fully typed. SupportstopN, per-request cancellation viaabortSignal, and the standard observe-onlyGenerationMiddleware(onStart/onUsage/onFinish/onAbort/onError) plusrerank:*devtools events. Rerank bills in provider-defined search units, surfaced onusage.unitsBilled.The first adapter ships in the new
@tanstack/ai-coherepackage ascohereRerank/createCohereRerank.
Minor Changes
ai: add
timeoutandabortSignalto media generation activitiesMedia activities (
generateImage,generateAudio,generateVideo,generateSpeech,generateTranscription, andsummarize) now accept optionaltimeoutandabortSignal. Core composes them into a request-specific effective signal, races the adapter call so hung providers reject, clears timeout resources on settle, and routes aborts to middlewareonAbort(notonError).@tanstack/ai-falforwards the signal tofal.subscribe()/fal.queue.submit()per request — never via globalfal.config()— so concurrent generations stay isolated.Add a multimodal
embed()activity. A single primitive covers one input or a batch —inputaccepts a string, a text part, an image part, or a fused text+image item written as a nestedArray<ContentPart>([textPart, imagePart], the same shape chat messages use), one vector per item, with the accepted item types narrowed per model at compile time. Top-leveldimensionsrequests Matryoshka output sizes where supported. Results carryembeddings: [{ vector, index }]plususagewhen the provider reports it, andembed()participates in generation middleware, debug logging, OTel (gen_ai.operation.name: embeddings), and devtools events like every other activity.Provider adapters:
openaiEmbedding(text-embedding-3-small/large),geminiEmbedding(gemini-embedding-001),mistralEmbedding(mistral-embed, codestral-embed),ollamaEmbedding(nomic-embed-text and any local model),bedrockEmbedding(Titan Text V2, Titan Multimodal G1 with fused text+image, Cohere Embed v3 on Bedrock), and@tanstack/ai-cohere'scohereEmbedding(embed-v4.0, multimodal text+image with requiredinputType).add
rerank()activity for reordering documents by relevance to a queryAdds a provider-agnostic
rerank()activity (withcreateRerankOptions, theRerankAdapterinterface, andBaseRerankAdapter). Documents may be strings or JSON-serializable objects — object documents are serialized for the provider and the original element is returned in the result, fully typed. SupportstopN, per-request cancellation viaabortSignal, and the standard observe-onlyGenerationMiddleware(onStart/onUsage/onFinish/onAbort/onError) plusrerank:*devtools events. Rerank bills in provider-defined search units, surfaced onusage.unitsBilled.The first adapter ships in the new
@tanstack/ai-coherepackage ascohereRerank/createCohereRerank.
Patch Changes
Preserve stable UI message IDs when converting UI messages to model messages.
Fix OpenTelemetry root spans to report usage across all chat iterations, including error and abort exits.
Preserve
UIMessage.createdAtwhen converting messages to and fromModelMessageso persisted transcripts retain their original timestamps.toRunErrorPayloadnow falls back to a numericstatusfield when a thrown error carries nocode. Some SDK error classes report the HTTP status only asstatus: numberand nocode(for example Google's@google/genaiApiError), so their status was previously dropped and the resultingRUN_ERRORevent surfacedcode: undefined— indistinguishable from an unknown failure. A stringstatus(an HTTP reason phrase such as"Forbidden"or a symbolic status such as"PERMISSION_DENIED") is intentionally ignored so only the numeric HTTP code is forwarded; an explicitcodestill wins.Fix provider-specific model options inference for TTS, transcription, and summarize activities.
publish internal dependency ranges as
^x.y.zinstead of exact pinsInternal dependencies on other TanStack AI packages used
workspace:*independenciesandpeerDependencies. pnpm rewrites that to an exact version at publish time, so a released package asked for e.g.@tanstack/ai-utils@0.4.0rather than^0.4.0.Two consequences for consumers:
Duplicate copies. An exact pin cannot dedupe. Installing a newer
@tanstack/aialongside a package pinned to the previous patch produced two copies in the tree, which breaksinstanceofchecks and module-level state, and inflates bundles.Unsatisfiable peers. An exactly pinned
peerDependencyconflicts the moment the internal package ships its next patch, forcing consumers into overrides or--legacy-peer-deps.
These fields now use
workspace:^, which publishes as^x.y.z. Every package here is still0.x, so^0.43.1resolves to0.43.xonly — patches dedupe cleanly and no breaking minor is ever pulled in.devDependenciesdeliberately keepworkspace:*: they are never published, and*correctly means "always build against the local copy".
- 0.43.x
0.43.1
7 Aug 2026otelMiddleware: open an iteration span for structured-output finalization
No-tools +
outputSchemacalls skip the agent loop and only run the structured-output finalization request. That path previously emitted only a rootchatspan — no generation record, andcaptureContentwas a silent no-op.onConfignow also opens an iteration span whenphase === 'structuredOutput', so each provider model call is observable. Native-combined mode is unaffected (it never fires that phase).
0.43.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.42.x
0.42.0
20 Jul 2026Reading the note…
- 0.41.x
0.41.0
16 Jul 2026Reading the note…
- 0.40.x
0.40.0
6 Jul 2026Reading the note…
- 0.39.x
0.39.1
3 Jul 2026Reading the note…
0.39.0
30 Jun 2026Reading the note…
- 0.38.x
0.38.0
26 Jun 2026Reading the note…
- 0.37.x
0.37.0
25 Jun 2026Reading the note…
- 0.36.x
0.36.0
25 Jun 2026Reading the note…
- 0.35.x
0.35.0
24 Jun 2026Reading the note…
- 0.34.x
0.34.1
24 Jun 2026Reading the note…
0.34.0
23 Jun 2026Reading the note…
- 0.33.x
0.33.0
22 Jun 2026Reading the note…
- 0.32.x
0.32.0
18 Jun 2026Reading the note…
- 0.31.x
0.31.0
16 Jun 2026Reading the note…