Repository navigation
@tanstack/ai-harness@0.1.0
Minor Changes
-
#1755
39c1c79- Add@tanstack/ai-harness: build an agent harness on TanStack AI. A harness keeps one conversation open across many turns.defineHarness()names an agent: its adapter, tools, system prompts, middleware, agents, subagents, routing, plugins, turn hooks, and durability options.createHarnessHost()opens sessions. Eachhost.opengives its own handle to the live session of the thread.session.close(), orawait using, releases the handle. A dropped handle is released by the garbage collector, with one warning. Afterclose(), each call on the handle throws. When the session closes under a handle that is not released (its log failed, or a delete ran), the next call on the handle opens it again.lifecycleoncreateHarnessHost()sets how sessions live. A session that no handle holds closes after it was idle forcloseIdleSessionsAfterMs(default 60 000).maxOpenSessions(default 500) first closes the least recently used idle sessions. A session is not idle while a background job of this host runs.retention(keepSettledOperations,keepSettledAgentRuns,maxEventBytesInMemory) bounds the settled operations, agent runs, receipts, events, and log tail that each session keeps in memory. The log keeps theMESSAGES_SNAPSHOTthat core sends when a run pauses at an interrupt, so a rejoin from the log rebuilds the paused run.host.close({ recoverable })stops the work of every session and refuses later opens. Withrecoverable: true, the host gives each log that has work back with a wake, so a worker takes it at once.host.observe(harness, { threadId, user })reads a thread with no session: its events, transcript, snapshot, media, usage, and entry. It sets up no plugins, writes nothing, and never takes the log, so a read works while another host owns the thread. Its snapshot follows the same rules assession.snapshot(), without what only an open session knows (a running turn, operations, queues).view.logState()reads thedurability.logStatefold.host.sessions.deletecloses the sessions of a thread and of its child threads. Then it deletes the log of the session tree (with the settings, config, plugin state, interrupted turn, runs, interrupts, usage, revert data, and the inputs that wait), the index entries, the uploaded media, and the generated artifacts. The first-party plugins also end the thread:workspaceTools()closes an owned sandbox with'closed', andsnapshots()removes the shadow repository. This needs the index entry, and the harness on that host: a host that opened it, or one with it in theharnessesoption ofcreateHarnessHost(). Else the host warns. A child thread is ended in the log of its root. While the delete runs, an open of the thread throws. Withaccess, a thread with no index entry throwsAccessDeniedError.- A session takes
send(a prompt, a steer into the running turn, a follow-up, or a message to an agent run withto),continueWithoutMessage,reset,resolve,cancel, andmoveToolCallToBackgroundinputs. Each returns an operation with a receipt, and the session streams typed events.host.isOpen(harness, { threadId })says whether this host holds that session open. - The session methods take one object:
cancelInput({ inputId, user? }),revert({ messageId, user? }),background({ toolCallId?, user? }),getMedia({ id }),mediaUrl({ id }), andagentTranscript({ subagentRunId })oragentTranscript({ runId }).AgentRun.subagentRunIdandAgentRunInfo.subagentRunIdname the child id of an agent run, so the transcript of a run that code or a background call starts is found too. session.appendRecords({ records, inputId?, user? })returns aReceipt. A second append with the sameinputIdwrites nothing. WithoutinputId, the append gets a new input id, and the log keeps it as an input. Theharness.revertrecord keeps the user ofrevert, and theharness.jobrecord keeps the user ofbackground.createHarnessHost({ access })decides who can use each session.getUser({ request })reads the user of a request, andisAllowed({ user, action, session, run })decides each action. The user type comes from whatgetUserreturns. The host keeps each tenant apart, needs auseron each call, andhost.openreturns a handle that acts only as its user. A refused action throwsAccessDeniedError. The operations and the agent runs that a handle returns check access too. A child thread is checked through its root, also onhost.open, so a stranger does not learn its root. When one user answers the turn of another user, the rest of the turn runs as the user who sent it. Withoutaccess, one user runs the host, with no checks. Withaccess, keyed adapters and provider keys read env vars only whenaccess.allowServerEnvKeysistrue.membersOnly()gives ready access rules with the roles owner, member, operator, and viewer.accessis{ getUser, rules, getGuestUser?, allowServerEnvKeys? }, andmembersOnly()returns rules:{ getUser, rules: membersOnly() }. Rules are{ isAllowed, filterSessionList?, members? }:filterSessionListis the store filter ofhost.sessions.list, andmembersturns onhost.sessions.shareandunshare(members.forksets the members of a fork). Your own rules arerules: { isAllowed }.host.sessions.shareandhost.sessions.unsharechange the members, andhost.sessions.forktakesmembers: 'self' | 'all'. The owner is the member with the roleowner. For apps with their own access tables,host.revoketakes the access of a user to a thread, and returns aPromise.host.revokeandunsharecancel the running and queued work of the user on every host, and remove the schedules of the user at once. When that work cannot be stopped, they reject, and the access is gone already. A runningevents()read of a handle or a view ends withAccessDeniedError, and later view calls throw.host.getUserOfRequestreads the user of a request for your own routes. Eachhost.sessionsmethod takes one object.- A user with a
tenantIdkeeps its sessions in its tenant: one thread id in two tenants is two sessions, with two index entries and two logs.logIdOf({ tenantId, logId })gives the id of a log in the store. - Every run has a run context with its user, and that user's credentials and provider keys:
ctx.context.runin tools and middleware (type them withHarnessRunContext),ctx.turn.runin a turn plugin, andrunin a command. Thectx.credentialsandctx.keysof a session plugin have no user, so each call throws.ctx.agents.start(agent, input, { user })starts an agent for a user.AgentRun.sendtakesuser?, and thesendandcancelof a run act as the user of the run.session.agents.<name>.runhonorsuser. The log keeps the sender of every input. The run context also hasepoch, the ownership epoch of the log, andidempotencyKey(step, scope?), a key for one side effect that is the same when the run runs again on any host. definePlugin()adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn. Withlifetime: 'host', the host sets a plugin object up once per harness and shares it with every session that uses the same object. It is cleaned up when the last session that uses it closes, or athost.close(). A prompt withplacement: 'context'goes in one system prompt after all the others, made again at each model call, so text that changes often (the todo list, the goal status, plan mode) leaves the system prompts before it in the provider prompt cache. The defaultplacement: 'system'keeps a prompt in the system prompts.prepareToolsgetsrun.onTurnStart(turn)runs once at the start of each turn, before the prompts, so a plugin can refresh the state that its prompts read. A plugin'sctx.session.sendandnotetakeuser?, and default to the session owner. A thread that removes a plugin cannot run the agents of that plugin by name.routing.routersends a turn to a root agent. The log keeps the active agent, and the router reads it asactiveAgent. A router that returnsundefinedgives the turn to the active agent, else to the main model. One agent name makes that agent active,'main'clears it, and{ handoff, note }is a handoff from the router.routing.afterAgentsAnswer: 'end-turn' | 'main-answers'(default'end-turn') says whether the main model answers after the routed agents.- Handoffs move a conversation to another root agent, and the target answers in the same turn. With
routing, the main model and each root agent get ahandofftool. Code hands off withctx.harness.handoff({ to, note })in a root agent, and withctx.context.run.handoff(...)(RunContext.handoff) in a tool of the main model. Each handoff writes aharness.handoffrecord and streams aharness.handoffevent (from,to,note,by,runId). A message that an agent writes hasmetadata.harness.agent. A turn hands off 10 times at most. An app tool namedhandofffails the turn.routing.giveModelsHandoffTool: falsegives no model thehandofftool, and the router and code can still hand off. A handoff stops only the chat that made it. When the part that handed off also stops for interrupts, the resolve turn runs the target (or pauses for a person). The router writes a handoff to the main model once when the active agent is gone. A handoff from the router or from code adds ahandofftool call and its result to the transcript before the target answers, so the target sees why it got the conversation. Withrouting.giveModelsHandoffTool: false, it adds one assistant note. - With
routing.allowHandoffToHuman: true, a handoff to{ human: true }opens ahumanwait with no default expiry, and the turn endsinterrupted. User messages during the pause go in the transcript, and no model answers them.session.send(text, { as: 'operator' })adds an operator reply. It needs theoperatoraccess action, and it is rejected withno_human_waitwhen no handoff to a person is open. The operator ends the pause with an answer to thehumanwait:{ resume: true }or{ handBack: '<agent>' | 'main' }, also to a root agent of a turn plugin. When thehumanwait ends in any other way (a resolve, a cancel, an expiry), the agent from before the handoff answers again. An operator reply with files keeps its text in the assistant message, and the files in a user message after it withmetadata.harness.operatorAttachment. A view shows the files with the reply, live and after a reload. host.transfer({ from, to, envelope, user, transferId })moves a conversation to another thread, also of another harness, in the same tenant. Only the envelope moves: a note, then no message, a summary that the source model writes, or the settled transcript with its media. The user needstransferon the source andsendon a target that exists. A new target has the user as its only owner. Both logs get aharness.transferredrecord, the index entries link the threads inmetadata,host.events()yields atransferredevent, and a retry with the sametransferIdis safe. A source thread that does not exist throws. A fork stays in its harness:host.forkwith a thread of another harness throws.- Agents run from code with
session.agents.<name>.run()and.start().agentRun.receiptresolves. In agent code,ctx.harnessgivesstep.do()(a finished step returns its stored value when the run runs again),step.sleep({ name, ms }),agents,state, andhandoff(in a root agent).ctx.harnessis optional: a run that a plainchat()starts has none. ctx.harness.agentsin agent code andctx.agentsin a plugin have one type,AgentActions:start(agent, input, options?),run(agent, input, options?), andgroup({ concurrency, onFailure, key }, (g) => [g.start(...)]). The input type comes from the input schema of the agent, and the result type from its output.groupresolves to the results in order. WithonFailure: 'collect', each entry is anAgentSettled({ ok, value }or{ ok, error }). The children of a group over itsconcurrencywait asqueued.- Each start takes
key,limits: { timeoutMs, maxAttempts },budget: { maxTokens, maxCost }, andonModelError. A run over its budget fails with the codebudget, after the first model call over it. A run that runs again after a host stop counts the model use of the runs before. A run pastlimits.timeoutMsfails with the codetimeout. A child never has more budget than its parent has left.onModelErroris asked when a model call fails before it streams, and'retry'calls only that model call again (at most 3 calls). As data,onModelError: { retries: 1 | 2, transientOnly? }retries up toretriestimes (by default only a transient error), and the log keeps it, so a run that another host takes over or resumes retries too. A function is not in the log, so a run that another host takes over runs without it. Actx.agentsstart in a plugin tool that an agent run calls is a child of that run, so it takes nomaxConcurrentslot and the run can await it.parent: ctx.context.runnames the run, andparent: nullstarts a root run. Aparentthat names no running agent run of this session throws, so a mistake does not start a root run. A running turn of the session gives a root run. - The log keeps every run of a session as a
RunNodeof one run tree (turn,agent,tool, orcommand), with its parent, its user, its limits, its budget, and its result.agentRuns()reads the tree, andAgentRunInfohasrunId,kind,budget,result, anderror. Eachharness.usagerecord has therunIdof its run, and an agent run that ends keeps the usage of the run and its children. maxDepthandmaxCallscount per tree, andmaxConcurrentcounts the root agent runs of the session. OvermaxConcurrent, a root start waits asqueued. The log keeps the counts, so a restart does not reset them. Aqueuedstart survives a host stop: the next host starts it with the same run id, in order, when a slot is free.- A child run gets the user, the keys, and the signal of its parent: a cancel, a timeout, or a budget stop of the parent stops the child. A cancel of a turn also cancels the background runs that it started. A background run that a turn starts keeps the turn plugins until it settles. An agent that the model calls as a tool, and a routed agent, get
ctx.harnesstoo. - A run with
runAgainAfterCrash: truethat runs again after a host stop gets the children that it started before, with their stored results, and starts only the rest. A background child that the model starts (background: true) runs again after a host stop as a run withrunAgainAfterCrash: true, with the samesubagentRunId.durability.resumeBackgroundAgents: falsefails it instead. ctx.harness.state({ key, initial })gives the agents of a session a shared state by key, withgetandupdate. The log keeps it, and a fork copies it.STATE_SNAPSHOThas{ plugins, shared }.- A routed agent takes steers at its next model call, and late joins in the same turn, and
turn.beforeFinishandturn.onModelErrorrun for it. - Every host keeps each session tree in one log,
stores.logfrom@tanstack/ai-persistence.memoryPersistence()has a memory log, and a host with nopersistenceuses it. The log holds the inputs, events, transcripts, runs, interrupts, settings, config, plugin state, agent results, schedules, and tool steps, and the host folds them when a thread opens. A root thread and its child threads (agent runs and subagents) write to the log of the root.session.logState()reads thedurability.logStatefold of the whole tree. A harness with anotherdurability.addRecordsToModelContextordurability.logStatethan the one that folds an open log throws when it opens that log. With a log store that survives a restart, the next host recovers a turn that a crash stopped. - One host owns a log at a time: the first write takes it, one timer per host renews it, and a host that loses it stops its work on it. A host gives a log back when it has no work. Another host recovers the work of a log only after its ownership expires. A read never takes the log. An input that reaches another host waits in the input lane of the log, and the owner runs it (receipt
queued) with its JSON overrides (model,modelOptions,reasoning, andpromptCache). A command that reaches another host ends as it ends on the owner: with its result, its error, orInputRejectedError. An input to a thread that was deleted is refused: the receipt isrejectedwith the reasondeleted, and an awaited operation rejects withInputRejectedError("The thread was deleted.").ownership: { expiresAfterMs, renewEveryMs }sets the timing.renewEveryMsmust be less thanexpiresAfterMs, elsecreateHarnessHostthrows aRangeError. With onlyexpiresAfterMs,renewEveryMsis a third of it. - When the owner refuses an offered input (busy, or an id that the log has with another payload),
session.settled()on the host that offered it rejects withInputRejectedError. The log gets aharness.offer.rejectedrecord. An offer that comes while the owner appends is not missed. - A host that does not own a thread opens it while the owner runs a background job. Settings, config values, and the interrupted turn follow the changes of other hosts.
updateThreadSettingsmerges into the latest stored settings. - The owner writes a snapshot of the folded log to the log store after
log.saveSnapshotAfter.bytesof new records (default 1 000 000) orlog.saveSnapshotAfter.ms(default 300 000), and a new host opens from it. Withlifecycle.retention.keepLogRecords: 'since-snapshot'(default'all'), the owner truncates the records before each snapshot. A reader whose cursor is in the dropped part gets oneharness.gapevent (EventGap), then the stream goes on. With'since-snapshot', a thread whose snapshot the host cannot use (a changedreduceversion orproject) cannot open, and rejects withLogTruncatedError.session.revert()works with'since-snapshot': the snapshot keeps the last 500 file steps. A plugin sets which of its records the snapshot keeps withkeepRecords: { [type]: count }on its definition, and reads them, oldest first, withctx.session.keptRecords(type). The fold keeps the last 100 ended runs, run nodes, interrupts, and agent results, and drops the turn overrides of a settled input, so a long thread does not grow its fold and its snapshot with each turn. host.submitstores an input for a thread and runs nothing.session.schedule({ at, input, scheduleId, user })runs an input later, andsession.cancelSchedule({ scheduleId })stops it.createHarnessWorker({ host, harnesses, concurrency, signal, pollMs })runs the threads that have work and no live host, and returns{ stop }.host.resumePendingdoes one sweep. A swept thread that fails to open goes back with a wake in 30 s. A worker skips a thread that fails to open for a time that doubles after each failure in a row, up to 5 minutes, so the other threads still run.host.submitthrows for a thread of another harness, and a later offer never changes the harness of a log.durableTool()keeps the result of each step. A crash can cut a step, and then it runs again, sostep.dopasses{ idempotencyKey, epoch }tofn. Give the key to your system, so it refuses the second side effect.step.ask({ name, message, schema })asks the user: the log keeps the question, any host can take the answer, and a run again after a stop returns the logged answer. Astep.dovalue must be JSON: aDate, aMap, or a class instance is a type error.step.sleep({ name, ms })waits in the process, and the log keeps the wake time, so a worker continues the call after a stop. A sleep longer than 30 s on a thread that only a worker runs gives the thread back with the wake, so it keeps no worker slot.durability.truncatedToolResultsets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn.turn.onModelError,retryTransientErrors(),turn.beforeFinish, andturn.canJoincontrol how a turn retries, continues, and ends.- Every approval, question, sign-in, client tool, and human takeover that a run waits for is a
Waitin the session log (waitId,kind,runId,schema,expiresAt,message).WaitKindisapproval,question,sign-in,client-tool, orhuman.waitKind,isApproval,waitFromInterrupt, andwaitFromQuestioncome from the root and from./view.snapshot().pendingWaitslists the open waits: each one with itsinterruptwhen a chat run stopped for it, andturn: truefor a wait of the stopped turn. It is the one wait view of a snapshot. andsession.waitForRun({ runId, signal })resolves with the next one. A wait endsanswered,cancelled, orexpired. A wait withexpiresAtexpires on time. Onlysign-inhas a default limit: 15 minutes. - A wait that only the log holds keeps no host busy: the session goes idle, and an answer on any host continues the run.
resolveanswers the waits with aninterrupt: of a stopped turn, or of one background agent run per call.answeranswers the waits with nointerrupt(astep.askquestion, an MCP question, thehumanwait of a handoff), checks the answer against the JSON Schema of the wait, and keeps a secret out of the log. Aresolveof such a wait is refused withuse_answer, and a second answer to a wait that one answer ends is refused.host.submitchecks an answer against the wait that it answers. - A run waits for a person only at fixed points:
permissions()before a tool call, thequestiontool,step.askin a durable tool, a command (CommandContext.step), or an agent run, and the live waits of MCP elicitation and OAuth sign-in.ctx.session.askthrows anywhere else, with an error that namesstep.ask, thequestiontool, andPermissionResources. A command that calledstep.askruns again from the top after a host stop.QuestiontakesexpiresAtandsignal. - A background agent run stops with status
waitingon a wait, for example an approval ofpermissions(), and continues after the answer, also after a restart. An agent that the model calls as a tool inside a turn waits with the turn: its approvals, questions, sign-ins, app interrupts, client tools, andctx.harness.step.askquestions are waits of the turn insnapshot().pendingWaits, withagentandsubagentRunId. After the resolve, also on another host or after a restart, the agent goes on from where it stopped, and its result goes to the model as the tool result. Itsctx.harness.step.dovalues are in the log. Withdurability.agentToolWaits: false, the agent does not wait: a call of it that needs an approval is refused. - The
operatoraccess action answers ahumanwait of any run, also with a resolve. Ahumanwait takes onlyoperator.membersOnly()gives it only to the roleoperator, so the user who waits for a person cannot act as that person.membersOnly({ operator: { roles } })sets the roles that have it.isAllowedgets thewait(waitId,kind) of anansweroroperatoraction. session.settled({ inputId, signal })takes one object.signalstops the wait for the settlement.session.onQuestion,session.onAuthRequired,session.onMedia, andsession.onUsagecall a listener for the events that a user answers or sees, with a typed value.- Sessions also have thread settings, fork and revert, usage totals, media files, the transcript of each child agent (
session.agentTranscript), andharnessText()/harnessAgent()to use a harness as a text adapter or an agent. In a session, aharnessAgentchild is a child run in the log of the root, with the user, keys, limits, and budget of the run that calls it. Its usage and its waits show in the parent session, on every host and inhost.observe. An answer on another host goes to the host that runs the child. The base64 cache of media files is shared by all the sessions of a host. harnessText()uses the user of each call. The inner thread is<outer thread>/inner/<harness>on every host, andhost.openrefuses that id from app code. When a host with access runs the outer thread too, the inner session is checked through the outer thread, so every user of a shared outer thread can use it. The index entry of the inner thread keeps the link to its outer thread. An abort on a remote harness sends a cancel. The usage of the inner run reaches the outerRUN_FINISHED. Its SSE reader takes multi-linedata:and CRLF.harnessText()andharnessAgent()forward events on one shared path.createHarnessHost({ observability: { tracer, onOperation } })traces runs.tracertakes an OpenTelemetryTracer.@opentelemetry/apiis an optional peer dependency: the host loads it only when it has a tracer. The host writesharness.operationspans (turns, agent runs, commands), andharness.recovery,harness.wait, andharness.transferspans. A child run on the same host is a child span: the host passestrace.setSpan(context.active(), parent)as the third argument ofstartSpan. A tracer that throws is logged and fails no run.SendOptions.traceparentstores a W3Ctraceparentwith the input, and the run span on the host that runs it gets a link to it.onOperationgets astartedreport and asettledreport (OperationReport) for each operation that this host runs, withstatus,durationMs,error, andusage. A listener that throws gets aharness.plugin.warningevent, and the work goes on.- More lifecycle events in the session stream:
harness.input.queued,harness.ownership.changed(reason: 'taken_over'when the store says that the old owner expired, else'acquired'),harness.stranded,harness.transferred,harness.wait.opened, andharness.wait.settled.HarnessEventValuesmaps eachharness.*event name to the type of its value. onEvent,onQuestion,onUsage, andonMediaalso get the events that another host writes to the log of the session. An event fires once. The status ofhost.events()counts that work too, for the sessions that are open on the host.- The router gets
model:model.chat({ adapter, options, label })inrouting.router(HarnessRouterContext.model) calls a model with the keys of the sender, and a cancel of the turn stops it. session.usage()hasbySource: the model calls that are not a turn or an agent run, keyed byplugin:<name>,router, ortransfer. EachUsageCallandharness.usageevent of such a call hassource, andlabelwhen the call has one. The summary ofhost.transferwithmessages: 'summary'counts in the source thread undertransfer, with the labelsummary, and stops after 2 minutes.
Add first-party plugins.
@tanstack/ai-harness/pluginsloads no Node module. The plugins that read files are in the Node entry@tanstack/ai-harness/plugins/node.permissions(): rules for tools, paths, and commands, and thedefault,plan,acceptEdits, andbypassmodes. It decides every call of a model message before the calls run, and opens one approval wait for each call that asks, so the turn stops and no process waits. An answer istrueorfalse,'once','always', or'reject', or{ answer, message? }. It also has saved "always" rules with/permissionsand/permissions forget <n>, andlistSavedPermissions/deleteSavedPermissionfor server code. The rules apply in subagent runs too. It decides inonGuardToolCall, on the final checked arguments.agents()(inplugins/node): agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files,/agentto switch the primary agent, and the built-inbuild,plan,general, andexploreagents. A subagent run uses the tools and the model of the turn that started it. A run whose turn is not in the memory of this host fails with a clear error. A run that code starts needs a profilemodel.question(): aquestiontool for the lead turn. Each question is a wait of the turn, answered withresolve. Agent runs do not get the tool.todos(),title(),modelPicker(),compact(), andusage()(with your own prices for calls that have no provider cost).goal()runs each turn as the user who set the goal.projectInstructions(),fileCommands(), andboundToolOutput(), inplugins/node.
A plugin runs agents with
ctx.agents.start,ctx.agents.run, andctx.agents.group({ concurrency }, (g) => [g.start(agent, input)]), with the same types asctx.harness.agentsin agent code.PermissionRules,PermissionResources,PermissionDecisionCapability, anddecidePermissionare also exported from the package root. They load no Node module, so an edge-safe plugin can read the permission rules.ctx.model.chat({ adapter, options, label, run, timeoutMs })calls a model from a plugin, for example a judge or a title. It uses the keys of the user of the run, the budget of the run applies, and it stops when the run stops or aftertimeoutMs(default 2 minutes). Its usage counts in the session underplugin:<name>insession.usage().bySource.goal(),title(), andcompact()call their models this way, with the labelsjudge,title, andcompact.Serve a harness session to clients.
createHarnessHandler()from@tanstack/ai-harness/serverserves a session over HTTP, as the user that the hostaccessreads from each request.POST runis standard AG-UI, souseChatwithfetchServerSentEventsworks on a harness, with approvals, client tools, and a reload that joins the running turn.POST control,GET events,GET transcript,GET agent-transcript(the messages of a child agent run),GET snapshot, andGET describecover one session.sessions(with theshareandunshareops),GET host-events, and the media routes cover the rest. Ahost.events()reader listens from the call, and one that falls more than 1 000 changes behind stops, and reads again for the current statuses. A refused action answers 403. A read of a thread that does not exist answers 404 and starts no session. A signed media URL still needs a user:getUserreads the user of the request, and that user needsreadon the thread, in the tenant of the URL. For a request with no login,access.getGuestUser({ tenantId })can give a guest, andisAlloweddecidesreadfor it. The guest exists only on the signed media route.requestUsersfrom@tanstack/ai-harness/servergives the user of each request with the same rule, for your own server. The read routes read a thread withhost.observe, so they set up no plugins.GET describeopens the session.handleHarnessSocket(), from the same entry, serves the same session over a WebSocket. It takes the upgraderequestand reads the user from it. It handles the frames of one connection in order.- A
commandor anagentinput over HTTP or the socket runs once for oneinputId, and theinputIdof its receipt is the id of the client. exposeondefineHarness()names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds getsrejectedwithnot_exposed.createHarnessClient()from@tanstack/ai-harness/clientis a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor. Withoutfrom,client.events()starts at the newest event (the cursor of the snapshot), so it does not send the whole history first. This is the default: a reader that needs the history (to rebuild a transcript, for example) must passfrom: '0'or a saved cursor. When the snapshot answer has no stringcursor,client.events()throws and does not retry.client.events()connects again afteridleTimeoutMs(default 60 000) with no bytes. Thepromptinput of the protocol andclient.sendtakeas: 'operator'for the reply of an operator after a handoff to a person.- The client methods take one object:
cancelInput,revert,agentTranscript,mediaUrl,appendRecords, andputMedia.client.sessions.list,rename,delete,update,fork,share, andunsharemanage the sessions.HarnessErrorCode, the type ofHarnessProtocolError.code, comes from the main,/client, and/serverentries. oauthConnector()and the OAuth helpers from@tanstack/ai-harness/oauth(loopbackLogin,deviceLogin,exchangeCode,refreshCredential, and more) keep a credential per user. A tool callstoken({ run: ctx.context.run })for the token of the user of its run.token({ run, wait: true })stops the turn with anauth_requiredinterrupt until the user signs in.deviceLogin,loopbackLogin,startLoopbackReceiver,exchangeCode, andrefreshCredential(config, credential, { fetch, signal })stop at once when theirsignalaborts, so a cancel or a session close stops a running/connect.OAuthConfig.issuermakesloopbackLoginrefuse a code from another issuer (RFC 9207). The loopback deadline (timeoutMs) does not keep the process alive.run.credentials.refresh(id, update, { signal })refreshes a saved credential once for all callers on a host, keyed by tenant, user, and id. It saves withsetIfwhen the store hasgetVersionedandsetIf, and uses the other host's token on a conflict. Without that pair, it does a plainset. It saves to the scope where it found the credential, so a tenant token stays the tenant's.updategets a signal that onlyhost.close()aborts. An abort ofsignalrejects only that call.oauthConnectorrefreshes this way, andrefreshCredentialkeeps theclientof the old credential.- The session stream speaks protocol version 2 on every transport:
GET events, the socket, and the stdio worker. Each event is an AG-UI wire chunk in aharness.eventframe, withcursor,threadId, andoperationId. The AG-UIRUN_STARTEDof a child agent run hasparentRunId, so a client can rebuild the run tree. - The first frame of a stream is
harness.hellowith the versionv. A client asks for a version with?v=or withvinharness.subscribe. Another version gets aharness.errorframe with the codeunsupported_version, and the stream closes (socket code 4400). - A
harness.errorframe has acode(unauthorized,forbidden,invalid_frame,unsupported_version,slow_consumer, orinternal) and arequestId. On the socket and the stdio worker, therequestIdis the id of the frame that failed. Every answer of the handler has anX-Request-Idheader: the id that the client sent when it is a short safe id, else a new UUID. - Streams stay open and protect the server. SSE streams send a
: pingcomment and the socket sends aharness.pingframe when idle. SSE reads the log only as fast as the client reads, and the socket waits while itsbufferedAmountis over the limit. A client that does not read in time gets aslow_consumererror frame, and the stream closes (socket code 4408). A slow client loses no events: it connects again from its last cursor.createHarnessClientdoes this inevents()andhostEvents(), and ends the read with aHarnessProtocolError(with itscode) for any other error frame. presence: { onConnect, onDisconnect }oncreateHarnessHostreports each client stream ofGET eventsand of the socket (after its subscribe), as aPresenceEventwith a newconnectionId, the user, the thread, the transport, andresumed.onDisconnectruns once per stream, with the reasonclosed,revoked, orerror. The host does not wait for a hook, and a hook that fails only warns.stream: { heartbeatMs, slowConsumerMs, sseQueueBytes, socketBufferBytes }oncreateHarnessHandlerandhandleHarnessSocketchanges these values. The defaults are 20 s, 30 s, 1 MiB, and 8 MiB. A value that is not a finite number above 0 throws at the start.HarnessStreamOptionsis the type.- A reader whose events are gone gets one
harness.gapevent, withreason: 'truncated'(a truncate of the log) or'evicted'(the memory feed of a session dropped them). The stream then goes on fromresumeAt. - A W3C
traceparentgoes with a message: as thetraceparentheader ofPOST controlandPOST run, or as thetraceparentfield of aharness.inputframe.ClientSendOptions.traceparentmakesclient.sendsend the header. - A
cancelinput stops a turn from any host that reads the same log, soclient.cancel({ operationId })works behind a load balancer. A closed connection still never cancels work. To stop auseChatturn, callclient.cancelwith therunIdofuseChatin the stop handler.
Add
createSessionView()in@tanstack/ai-harness/view. It turns a session (local, or remote throughcreateHarnessClient()) into one live TanStack Store state that works with any UI library.- The state: messages with their parts, tool calls, approvals, client tools, other interrupts, questions, sign-ins,
waits(every open wait, with its kind), child agents with their results and astatusofrunningorwaiting, media with signed URLs, waiting inputs, usage, models, settings, commands, and plugin state. - The waits of background agent runs show in
approvals,clientTools, andinterrupts. The view answers each one at once: withresolve, or withanswerfor ahumanwait that no turn holds. - Actions:
send(a prompt, a steer, or a/command),followUp,continueWithoutMessage,putMedia,cancel,cancelInput,changeInputDelivery,updateThreadSettings,reset,revert,loadAgent, and the answers to approvals (with edited arguments), client tools, interrupts, and questions.cancelInput,revert,approve({ id, ...options }),reject({ id, payload? }),notice({ text }), andputMediatake one object, and so does thereject({ payload? })of an approval item. - Typed events:
view.on('approval' | 'interrupt' | 'turnEnd' | ..., handler)and plugin events. - In a harness with
routing, an assistant message hasagent, the root agent that wrote it. After a handoff, the next agent writes a new message in the same turn. view.send(text, { as: 'operator' })sends the reply of an operator while a handoff to a person is open. The reply shows in each open view at once.- A view that opens while a turn runs shows the turn from its start. After a reload, each child agent shows its card, and
view.loadAgent({ id })fills it with its own messages. - When the stream sends a
harness.gapevent, the view stops its read, loads the snapshot and the transcript again, then reads on from the new cursor. No turn shows twice.
Mark
createSessionView,SessionViewState, andSessionViewEventsas@experimental. Document that event cursors are opaque strings: positions in the session log.Add the
goal({ judge, maxRounds })plugin to@tanstack/ai-harness/plugins./goal <text>sets a goal and starts a turn. After each turn, the judge model checks the goal, and the plugin starts the next turn until the goal is met, the round limit is reached, a turn waits for approval or fails, or the user sends a message./goal,/goal stop, and/goal resumeshow and control it. TheGoalMetevent fires when the goal is met, andselectGoal(session.snapshot())reads the goal for a UI.Add the
providerKeys({ providers })plugin to@tanstack/ai-harness/plugins. Users of a shipped harness connect their own model provider in the app, with no.envfile. It adds/connect:<id>,/disconnect:<id>, and/keys./connect:<id>runs the provider'ssignInwhen it has one, else it openskeyUrlwhen set and asks the user to paste the key withstep.askandsecret: true. The log keeps the question as a wait, so a user on any host can answer it, and the log never keeps the key. The key is saved in the credential store of the user, and akeyedAdapter(...)for that provider uses it./keysshows where each key of the user comes from: a saved key (the last 4 characters only), the env var, or no key. The plugin state shows only the env vars that the session reads, as every member of the thread reads it. On a host with access withoutallowServerEnvKeys, no env var counts.Add coding tools in the new Node entry
@tanstack/ai-harness/plugins/coding:workspaceTools({ root })givesread_file(text pages, images, PDFs),write_file,edit_fileorpatch(editStyle),list_files,grep,bash(withbackground),webfetch, andwebsearch(with aSearchProvider). Withpermissions(), edits,bash, andwebfetchask first, and the rules also apply to the real path of a link.outside: 'ask'makes a path outsiderootneed an approval.permissions()decides outside paths and the real paths of links before the call runs, so a tool never asks while it runs. Thecwdthread setting gives each thread its own working folder.workspaceTools({ workspace: { bind, reattach? } })gives each thread its own workspace. The root of the workspace is the boundary of every tool. The session log keeps where the workspace is (harness.workspace.bound). On another host,reattachgives the workspace back (for example a resumed sandbox), or'pinned'or'gone'. A gone workspace sendstanstack/workspace-tools:stranded. With this plugin first,snapshots()andformatter()use the workspace of each thread, and theirrootis optional.createHarnessHost({ ownership: { hostId } })gives a host a stable id. Then a thread that uses a folder on that host is pinned to it, and other hosts refuse its tool calls. WithouthostId, threads are not pinned. A host withhostIdsends a heartbeat to the log store, and its pins lapse when no heartbeat came forownership.expiresAfterMs: then another host reattaches its threads.host.unpin({ harness, threadId })unpins a thread from a host that does not come back, once its ownership expired.workspaceRoot(base, ...parts)gives a folder inbasefor a tenant and a thread id. It encodes each part, so a thread id from a client cannot leavebaseor reach another thread.- Commands,
rg,git, and the formatters get only a short list of variables of the server process, plus theenvof the backend call. Git runs without the hooks and thefsmonitorof the project. - The tools resolve links with
realpathunder the file lock, and fail when a backend cannot resolve a path. A write fails when the file changed after the tool read it in the same chat run, so a lead and its subagents do not lose each other's edits. The file locks are shared by the sessions of a host, by the sandbox (or host) and the real path of the file. - Background jobs: at most 8 per session (
maxJobs), the newest 1 MiB of output per job, and the eventstanstack/workspace-tools:job-started,job-output, andjob-ended. A running command thatsession.moveToolCallToBackground()moves counts as a job, and its process id goes to the log. A job that an earlier host left running is stopped at the next tool call, with a note to the model. When the backend cannot check the job (for example with nops), the log keeps it, and the note says that it can still run. The wake turn of a job runs as the user who started it.session.jobs()andjobs()of ahost.observeview list the jobs that did not end (JobInfo: the job id, the thread, the command or tool name, who moved it, when it started, andrunningon this host). Thebashspill file has[redacted]in place of each secret of the session. read_filesends images and PDFs up to 256 KB.webfetchgives the model the first 256 KB of a page.- A
WorkspaceBackendruns the tools.hostBackendis the default.realpathis required.spawncan give ajobId,execcan give one withonStart, andkillJob,binding, andcloseare optional.killJobrejects when it cannot check that the process is the job. On Windows, commands do not run a program from the working folder by its name. snapshots()adds/undoand/redo, which restore only the files of the turn (git 2.26 or newer), file restore forsession.revert(), and a diff for each model step. Each thread of each tenant has its own shadow repository, which never stores.envfiles. A step keeps only the files of its own run. The steps and the undo point are in the session log, so another host with the same repository can undo. A revert uses the last 500 steps.host.sessions.deleteremoves the repository of the thread, andpurgeSnapshots()does it from your own code. A failed snapshot sendstanstack/snapshots:failed.formatter()formats each written file with the formatter of the project. Withpermissions(), the rules decide the formatter with the call that writes, before that call runs: one approval covers both, and the formatter runs after the write. Analwaysanswer is tied to a hash of the formatter config files. A write of a formatter config below the root, or undernode_modules, asks each time, and analwaysanswer saves no rule for that formatter run.WorkspaceHookslets other plugins run code after a read or a write.
@vscode/ripgrepandturndownare optional peer dependencies.@tanstack/ai-harness/buildand@tanstack/ai-harness/worker:buildHarnessbundles a harness with Bun into a worker artifact (harness.jsand aharness.manifest.jsonwith the sha256 digest), and can also build a single executable.artifactText(dir)checks the digest and uses the artifact as the model of achat()call, with one worker process per thread.runHarnessWorkerruns a harness on stdin and stdout with the session frames of the harness protocol.definePluginrefuses asetup()contribution, such asadapterortools, at the top level of a definition, where it would be ignored. Return it fromsetup().write_file,edit_file, andpatchsendFileChanged(tanstack/workspace-tools:file-changed) with a unified patch and its line counts for each file that they change. At the end of each model step,snapshots()sends it for the files that other tools, likebash, changed: at most 100 for each step./undo,/redo,session.revert(), andsession.unrevert()also send it for each file that they put back.Any server instance that reads the same log store can serve a run.
POST runsends the log cursor of each event as its SSEid. A client that is cut off sends the same request withLast-Event-ID, and any instance continues the stream from there. The turn does not run again.GET run?runId=&threadId=finds a turn that another instance runs. It streams the turn from the log ofthreadId, from its start or fromLast-Event-ID, until the turn ends. It reads the log from the start of the turn, not from the start of the log. A turn rebuilt after a restart (a retriedPOST run) also streams from its start. WithoutthreadId, only the instance that runs the turn finds it, and another instance answers400. The join request of aChatClientsends only the run id, so putthreadIdin the URL of the connection.GET run?threadId=shows a turn that another instance runs asactiveRun, so a reloadeduseChatjoins it.
A host that folds a
harness.*log record type it does not know (a newer harness wrote it, for example during a rolling deploy) reads the log but refuses to write it, withLogVersionError. A log snapshot from a newer harness is not used.createHarnessHost({ logger })takes aLoggerfrom@tanstack/aifor the warnings and errors of the harness and itschat()calls (default:ConsoleLogger). Plugins get it asctx.logger, and a logger that throws does not stop the harness.PermissionCallCapabilitygives a plugin that runs tool calls itself, like code mode, the decision thatpermissions()makes for one call:allow,denywith the error that the model gets, oraskwith the approval question. It uses the current mode, the savedalwaysanswers, and the resources of the call args, andsettle(reply)applies the user's answer as a direct approval does.Keep the secrets of a session out of what it keeps and sends.
- Each session replaces every secret that it gave out with
[redacted]. These are the values ofrun.keys(also from env vars) andrun.credentials, connector credentials and OAuth tokens, and secret answers (each string field of an answer with fields). It scrubs log records, feed events, plugin state (also in aMapor aSet), notes, the model context, and tool results, also after the own middleware of an agent run. It also scrubs the error frames of the HTTP handler, the WebSocket, and the stdio worker. It finds the raw, JSON-escaped, and URL-encoded forms, and skips values shorter than 6 characters. A secret split across two streaming deltas stays in those two events, and a secret that is JSON-escaped two times is not found. scrubSecretsfrom@tanstack/ai-harnessis the one from@tanstack/ai/byok.- A
step.dovalue that holds a secret of the session throws, on the first run and on a replay, so the log never keeps a secret. Read the secret outsidestep.do. - A retry of an input with a secret in it is a duplicate, not a conflict: the check compares the input without the secrets, as the log keeps it.
- The log keeps the final result of each tool call: the result after every
onAfterToolCallhook (also the agent's own middleware in an agent run), scrubbed. A replacement from a hook wins, also on an error. Recovery after a crash gives the model that result. - An answer to a
secret: truequestion that reaches a host that does not own the log goes through thecredentialsstore of the host, as a one-time reference with the credential typesecret_answerand anexpiresAt24 hours later. Only the reference goes into the input lane, and the owner reads and deletes it. The references that nobody read go when the question ends. A kept answer for a run again goes when the run ends. WithoutcreateHarnessHost({ credentials }), the answer is rejected withsecret_answer_needs_credentials. A used, expired, or unknown reference is rejected withunknown_secret_answer.credentials.listdoes not show thesecret_answertype.
Make the transport defaults safe for a server with many instances.
createHarnessHandlerandhandleHarnessSocketread the user of each request with the hostaccess. On a host withoutaccess, passopenAccess: true(no user) oropenAccess: { user }(one local user), else they throw at the start. So a server never serves every thread to everyone by mistake.media: { secret }signs the media URLs ofcreateHarnessHandler. Set the same secret on every instance, so a media URL that one instance signs works on every instance. Without it, the handler signs with a random key of its own, and a URL works only on that instance. A host withownershipoptions needsmedia, else the handler throws at the start. On another host withoutmedia, the first handler logs one warning. A signed URL names its harness and its user, and the handler checks that user again when it serves the file. A URL of a deleted thread answers 404.media: { keys: [newKey, oldKey] }rotates the key: the first key signs, and every key checks. Each key must be at least 32 bytes, else the handler throws at the start.oauthConnectorneedslogin. Use'device'on a server, and'loopback'only for a harness on the user's own machine, such as a CLI.
How a turn takes steers, settings, and plugin removals, and the size limit of client requests.
- A steer that arrives during a
session.runTool()turn waits, and then runs as its own turn. - A turn keeps the config values that it started with. A config change while it runs, for example
/mode bypass, applies from the next turn. configure({ settings: { plugins: { remove } } })removes everything that those plugins give the thread: also their agents, subagents, commands, agent and generation middleware, and the items that they add for other plugins (for example the permission rules ofagents()). A remove that leaves a plugin (of any lifetime) or a harness middleware without a capability that it needs is rejected.- When two plugins give a tool with the same name from
discoverTools, the first one stays, and clients get oneharness.plugin.warningevent that names both plugins. createHarnessHandlerreads each JSON body up tomedia.maxBodyBytes(default 32 MiB). A bigger body answers 413.handleHarnessSocketcloses the socket with code 1009 on a bigger frame. The WebSocket server reads the whole frame first, so also set its own frame limit (for examplemaxPayloadofws).- Client media is checked part by part before any part is stored, so a refused input stores nothing. A retried input with the same
inputIdstores its media no second time, and gets the first receipt. - A client input with a provider file handle (
source.type: 'file') is refused withmedia_url_not_allowedunless the harness setsmedia.allowProviderFiles: true. Server code can still send file handles. POST runmoves the inline bytes of the last user message to the media store, asPOST controldoes, so the transcript keeps aharness-media:reference. A refused part answers 413 (media_too_large), 415 (media_not_allowed), or 400 (media_invalid,media_url_not_allowed).
-
#1755
39c1c79- AddcomputerUse()in@tanstack/ai-computer-use/harness: computer use as a harness plugin.- Each screenshot goes to the media store of the session. The transcript and the log keep a small
harness-media:reference. - The model gets only the newest
keepScreenshotsscreenshots (3 by default). An older one goes to the model as a 1x1 image. - A call with OpenAI safety checks asks the user through
permissions(), each time. Withoutpermissions(), the call is refused. - The
planmode ofpermissions()refuses thecomputertool.
@tanstack/ai-harnessis an optional peer dependency of@tanstack/ai-computer-use.A plugin can now store a file with
ctx.session.putMedia(upload), and putmediaPart(record)in a tool result. - Each screenshot goes to the media store of the session. The transcript and the log keep a small
-
#1755
39c1c79-createHarnessHost({ credentials })takes theCredentialStore(from@tanstack/ai/byok) where users' API keys and OAuth sign-ins are saved. Without it, credentials stay in memory until the process stops.runClitakes the samecredentialsoption. -
#1883
39abb7e- Core support for the harness: subagent binding options, theonGuardToolCallhook, server-side BYOK keys, harness tool runs, checked streamedoutputSchemaresults, and resume fixes.Add the harness asks from the fifth findings list.
chat({ outputSchema, stream: true })now checks the full result against a Standard Schema. A failure ends withRUN_ERRORstructured-output-validation-failed, with the model text inmetadata.tanstack.rawText.session.send(prompt, { outputSchema })gives the turn a typedstructuredvalue. It is per send only, and the HTTP handler does not take it.- A server tool reads run capabilities with
context.get(capability)andcontext.getOptional(capability), for example the sandbox underwithSandbox. - Readers keep the full history after a durable compaction.
session.transcript(),GET transcript, and the chat hydration show every message. The model still gets the compacted context. createHarnessHost({ acceptInput })sees each input after the access check and before the log keeps it. It can change the input or refuse it.- A plugin
adapter()pick withoverride: truewins over the stored thread model. onModelErrorcan return{ retry: true, adapter }to retry on another model.ModelErrorContexthasadapterandmodel.- Middleware reads
ctx.context.run.sourceandlabelto tell a turn call from a plugin, router, or transfer call. host.sessions.archive()andunarchive(). Lists hide archived threads unless you passwithArchived: true.host.sessions.eraseUser({ harness, threadId, userId, replies })replaces one person's messages with[removed], deletes their media, and cuts the log before a new snapshot. The neweraseaccess action guards it.HarnessRecordis exported, for a typedLogStorewrapper.- Coding-tool file calls pass their abort signal through
WorkspaceBackendto the sandbox. withCompactionlimits (maxTokens,contextWindow,background.atTokens) can be a function of{ model }.
Run a harness tool with no model turn, add host records from a client, and add a per-run system preamble.
session.runTool({ name, args?, meta?, inputId?, user? })runs one harness tool with no model
turn, for example from a schedule or a webhook. The call goes through the
same middleware, input check, andpermissions()rules as a model call, and
the call and its result join the transcript. A tool that asks for approval
stops the turn with an interrupt. While interrupts wait, the call is refused
withpending_interrupts.metais echoed as atanstack.injectioncustom
event.argsis typed from the input schema of a harness tool.expose.toolslists the harness tools a client may run with a
{ op: 'tool', name, args?, meta? }input, or with the new
client.runTool({ name, args?, meta?, inputId? }). The list is typed from the harness
tools, anddefineHarnessthrows for a name that is not intools. Any
other tool is refused withnot_exposed. The capabilities document marks
each tool withexposed.expose.recordslists the host record types a client may add with an
{ op: 'append', records }input, or with the newclient.appendRecords({ records }).
A record of another type, or with athreadfield, is refused with
not_exposed. The session log keeps the records.createHarnessMcpServeradds atool_<name>tool for each tool in
expose.tools, and anappendtool whenexpose.recordshas types. A tool
input with its ownthreadIdfield throws when the server is created,
because MCP usesthreadIdto pick the thread.session.send(message, { systemPreamble })prepends per-run, server-only
system prompts ahead of the harness's own system prompts (for example
host-provided memory). A client input cannot set them.- A tool call in a harness turn gets
threadIdandrunIdin its execution
context.ToolExecutionContextin@tanstack/aihas the two new optional
fields for a host to set.
A host can add fields to the
ctxan agent'srungets.subagents.binding.extend(ctx)returns the fields to add. A child'sctx.chat({ subagents })passes it down, so the fields reach nested children too.SubagentContextExtensionis an empty interface thatSubagentRunContextincludes. A package augments it to type its fields, for examplectx.harnessfrom@tanstack/ai-harness. Make each field optional, because a run that a plainchat()starts has no host.
Add
interruptstoSubagentBinding.- A host sets
binding.interruptsto give interrupt definitions to everyctx.chatcall of an agent run. Then the host middleware inchatMiddlewarecan stop the child with these interrupts. - The own
interruptsof the call win. When a host definition has the same id as one of the call, the host definition is not added. - A child's
ctx.chat({ subagents })passes the definitions on to its own children. - A plain
chat()call does not get them.
Add
lastChatMiddlewaretoSubagentBinding. It runs after the own middleware of eachctx.chatcall of an agent run, so it sees the final model context and tool results. A child'sctx.chat({ subagents })passes it on, after the child's own.Add
validateJsonSchema(schema, value). It checks a value against a JSON Schema with@cfworker/json-schema, as a tool input is checked, but with no repair. The result has the Standard Schema shape:{ value }or{ issues }. The root also exports theStandardSchemaV1andStandardJSONSchemaV1types.An agent
runthat throws the shape ofMCPInputRequiredError(name,kind,request) stops the child assuspended, as a tool does, and the parent run stops with its interrupt. The interrupt has a generic binding and no tool call. Its id is theinterruptIdof the thrown value, elsemcp_input_<subagentRunId>, and its message is the error message. The resume runs the child again, andctx.resumehas the answer.Add
SubagentBinding.debug, thedebugoption for the model calls of child agents that set nodebugof their own.Add the
onGuardToolCallmiddleware hook for checks that must always run, such as permissions.- It runs for every tool call, after all
onBeforeToolCallhooks and after theinputSchemacheck.hookCtx.argsholds the final checked arguments that the tool gets. - The guard of every middleware runs, in order, until one returns a decision. An earlier
onBeforeToolCalldecision does not skip it. - Return
{ type: 'skip', result }to deny the call. The tool does not run, and the model getsresult, the same as anonBeforeToolCallskip. Return{ type: 'abort', reason }to abort the run. - It runs before a server tool runs and before a client tool goes to the client. A guard cannot change the arguments.
- The new
GuardToolCallDecisiontype is exported.
BYOK keys can now live on the server.
@tanstack/ai/byokexports theCredentialStorecontract, theCredentialandCredentialScopetypes, anddefineCredentialStore. ACredentialScopehastenantIdanduserId. ItsthreadIdis optional, and a store ignores it.@tanstack/ai/byok/serveradds:storedProviderKeys(store, scope): provider keys from theapi_keycredentials of a user, then of the tenant, then the provider'senvnames. Passkeys.adapter(...)tochat()andkeys.gettosubagents.keys.readCredential(store, scope, id): one credential, the user's own first, then the tenant's. A user with no tenant reads only their own.tenantScopeOf(scope): the tenant scope of a user scope, orundefinedwhen the scope has no user or no tenant.memoryCredentialStore(): a store in memory, for development and tests.envKeyEntry(provider): the firstprovider.envname that is set, with its value.readCredentialEntry(store, scope, id): reads a credential likereadCredential. It also tells you which scope had the credential (the user or the tenant), and gives its revision when the store hasgetVersioned.
CredentialStorehas two optional methods for compare-and-set writes,getVersionedandsetIf, with the same shape as the pair onMetadataStore. A store without them still works.memoryCredentialStorehas both.Keep the store in your own database or in a secrets manager, and encrypt the secrets at rest. An
oauthcredential can keep the OAuthclientit was issued to.listnever returns secret values.scrubSecretsfrom@tanstack/ai/byoknow also removes the JSON-escaped and URL-encoded forms of each secret. It skips a secret that is shorter than 6 characters, so a short value does not remove common words.Credentialhas a new type,secret_answer({ value, expiresAt }). The harness keeps a secret answer to a question there for a short time and deletes it once read. Leave this type out of a list of connected accounts.memoryCredentialStore().listreturns itsexpiresAt.Approved edits to tool arguments stay raw until the final input check.
@tanstack/ai: a resume no longer rejects approvededitedArgsthat do not fit the tool'sinputSchema. The edit goes throughonBeforeToolCalland the final input check, like the model's arguments. If it still does not fit, the approval is used, the tool does not run, and the model gets the input error as the tool result. The transcript keeps the approved edit as it was sent, but only when the call passed the check.@tanstack/ai-persistence:withPersistencebinds the client-tool phase of an approved tool call to its approval. After a restart, the client result resumes with the approval that came before it, and a stale or missing approval fails the resume.
A nested subagent that waits on an interrupt now continues after the resume under
withPersistence. Before, it ran again from its prompt. Core rebuilds the card of the nested child from the store and takes only its id from the client card.LoadChildtakes an optionalparentSubagentRunId, and returnsinterruptIdsandparentToolCallId.chat()asks the middlewareonShouldContinueafter it runs the pending tool calls of a resume, before the next model call. A middleware that returnsfalsethere ends the run with no more model calls. The loop strategy is not asked at that point.WebSocketLikehas an optionalbufferedAmount. A server can read it to stop sending to a slow socket. A socket without it works as before.
Patch Changes
-
#1876
a5c2af3- An input with no user no longer runs as the user who opened the session that owns the thread. A host with access refuses that input. -
#1755
39c1c79- Fix recovery, cancel, steer, and provider bugs, and fill small harness gaps.- Recovery: a resolve that another host recovers runs its continuation. Before, it settled
completedwith no answer. - Approvals: a tool that a middleware adds in
onConfigcan now be approved.chat()checks a durable resume after the wholeonConfigchain. - Cancel: a cancelled turn keeps the text it streamed, and ends with
TEXT_MESSAGE_ENDandRUN_FINISHED(outcome: cancelled). A turn that times out ends withRUN_ERROR(code: 'timeout'). - Steer: a steer from another sender gets a
queuedreceipt with its own operation.host.events()showsidleafter a turn that a steer joined. - The duplicate-input check no longer depends on the key order of the stored input.
TurnInfohasmodel,attempt, andsender. The log keeps the sender'sname.harness.input.acceptedcarries the message. The chat hydration haspendingWaits.ObservedSession.settled()reads a settlement with no open session. Another host'ssnapshot().pluginsshows the saved plugin state.- New options:
subagents.background: false,media.urlTtlMs,conversationSummarizer({ middleware }), and thebeforeWriteworkspace hook, which can refuse a write. - Plugin model calls (
ctx.model.chat) run the harness middleware. - Exports:
SHUTDOWN_REASON,BUDGET_REASON, andRUN_CANCEL_REASONfrom@tanstack/ai-harness,HARNESS_EVENTSfrom@tanstack/ai-harness/client, andChatHydrateOptionsandChatHydrationResultfrom@tanstack/ai-client. - Compaction drops a background summary that another model prepared.
- Sandbox file calls take an optional
{ signal }. - Anthropic:
outputSchemamoves keywords that Anthropic rejects intodescription, usage reports 1-hour cache writes ascacheWrite1hTokensagain, andreasoningon a model with no reasoning entry warns once. - OpenAI Responses: a message whose model call sent a reasoning item is marked with
metadata.tanstack.reasoningItem. A replay of that message without its reasoning item no longer sends itsmsg_andfc_ids. - Cloudflare: a
data:line with nochoicesno longer throws, and the SSE fix-up acceptsdata:with no space, CRLF line ends, and any case in the content type. - Structured output: a schema failure carries
rawText, and a parse failure has the codestructured-output-parse-failed. ChatClient.addToolResultanswers the interrupt when the tool's schemas differ, so the result is sent.- The persistence conformance suite no longer fails a correct store on a slow runner.
- Recovery: a resolve that another host recovers runs its continuation. Before, it settled
-
Updated dependencies [
fcbd199,39c1c79,d3b4cd4,959f43e,39c1c79,fa2a291,f7e7cce,f6a27bc,7a0a48b,468063c,39c1c79,39c1c79,39c1c79,fe83aa0,781a3a8,39abb7e,f1b3f65]:- @tanstack/ai@1.0.0
- @tanstack/ai-utils@1.0.0
- @tanstack/ai-persistence@1.0.0