Skip to content

@tanstack/ai-harness@0.1.0

Choose a tag to compare

@github-actions github-actions released this 22 Oct 15:18
· 40 commits to main since this release
Immutable release. Only release title and notes can be modified.
5651e07

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. Each host.open gives its own handle to the live session of the thread. session.close(), or await using, releases the handle. A dropped handle is released by the garbage collector, with one warning. After close(), 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.
    • lifecycle on createHarnessHost() sets how sessions live. A session that no handle holds closes after it was idle for closeIdleSessionsAfterMs (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 the MESSAGES_SNAPSHOT that 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. With recoverable: 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 as session.snapshot(), without what only an open session knows (a running turn, operations, queues). view.logState() reads the durability.logState fold.
    • host.sessions.delete closes 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', and snapshots() 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 the harnesses option of createHarnessHost(). 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. With access, a thread with no index entry throws AccessDeniedError.
    • A session takes send (a prompt, a steer into the running turn, a follow-up, or a message to an agent run with to), continueWithoutMessage, reset, resolve, cancel, and moveToolCallToBackground inputs. 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 }), and agentTranscript({ subagentRunId }) or agentTranscript({ runId }). AgentRun.subagentRunId and AgentRunInfo.subagentRunId name 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 a Receipt. A second append with the same inputId writes nothing. Without inputId, the append gets a new input id, and the log keeps it as an input. The harness.revert record keeps the user of revert, and the harness.job record keeps the user of background.
    • createHarnessHost({ access }) decides who can use each session. getUser({ request }) reads the user of a request, and isAllowed({ user, action, session, run }) decides each action. The user type comes from what getUser returns. The host keeps each tenant apart, needs a user on each call, and host.open returns a handle that acts only as its user. A refused action throws AccessDeniedError. The operations and the agent runs that a handle returns check access too. A child thread is checked through its root, also on host.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. Without access, one user runs the host, with no checks. With access, keyed adapters and provider keys read env vars only when access.allowServerEnvKeys is true.
    • membersOnly() gives ready access rules with the roles owner, member, operator, and viewer. access is { getUser, rules, getGuestUser?, allowServerEnvKeys? }, and membersOnly() returns rules: { getUser, rules: membersOnly() }. Rules are { isAllowed, filterSessionList?, members? }: filterSessionList is the store filter of host.sessions.list, and members turns on host.sessions.share and unshare (members.fork sets the members of a fork). Your own rules are rules: { isAllowed }. host.sessions.share and host.sessions.unshare change the members, and host.sessions.fork takes members: 'self' | 'all'. The owner is the member with the role owner. For apps with their own access tables, host.revoke takes the access of a user to a thread, and returns a Promise. host.revoke and unshare cancel 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 running events() read of a handle or a view ends with AccessDeniedError, and later view calls throw. host.getUserOfRequest reads the user of a request for your own routes. Each host.sessions method takes one object.
    • A user with a tenantId keeps 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.run in tools and middleware (type them with HarnessRunContext), ctx.turn.run in a turn plugin, and run in a command. The ctx.credentials and ctx.keys of a session plugin have no user, so each call throws. ctx.agents.start(agent, input, { user }) starts an agent for a user. AgentRun.send takes user?, and the send and cancel of a run act as the user of the run. session.agents.<name>.run honors user. The log keeps the sender of every input. The run context also has epoch, the ownership epoch of the log, and idempotencyKey(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. With lifetime: '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 at host.close(). A prompt with placement: '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 default placement: 'system' keeps a prompt in the system prompts. prepareTools gets run. 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's ctx.session.send and note take user?, and default to the session owner. A thread that removes a plugin cannot run the agents of that plugin by name.
    • routing.router sends a turn to a root agent. The log keeps the active agent, and the router reads it as activeAgent. A router that returns undefined gives 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 a handoff tool. Code hands off with ctx.harness.handoff({ to, note }) in a root agent, and with ctx.context.run.handoff(...) (RunContext.handoff) in a tool of the main model. Each handoff writes a harness.handoff record and streams a harness.handoff event (from, to, note, by, runId). A message that an agent writes has metadata.harness.agent. A turn hands off 10 times at most. An app tool named handoff fails the turn. routing.giveModelsHandoffTool: false gives no model the handoff tool, 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 a handoff tool call and its result to the transcript before the target answers, so the target sees why it got the conversation. With routing.giveModelsHandoffTool: false, it adds one assistant note.
    • With routing.allowHandoffToHuman: true, a handoff to { human: true } opens a human wait with no default expiry, and the turn ends interrupted. 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 the operator access action, and it is rejected with no_human_wait when no handoff to a person is open. The operator ends the pause with an answer to the human wait: { resume: true } or { handBack: '<agent>' | 'main' }, also to a root agent of a turn plugin. When the human wait 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 with metadata.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 needs transfer on the source and send on a target that exists. A new target has the user as its only owner. Both logs get a harness.transferred record, the index entries link the threads in metadata, host.events() yields a transferred event, and a retry with the same transferId is safe. A source thread that does not exist throws. A fork stays in its harness: host.fork with a thread of another harness throws.
    • Agents run from code with session.agents.<name>.run() and .start(). agentRun.receipt resolves. In agent code, ctx.harness gives step.do() (a finished step returns its stored value when the run runs again), step.sleep({ name, ms }), agents, state, and handoff (in a root agent). ctx.harness is optional: a run that a plain chat() starts has none.
    • ctx.harness.agents in agent code and ctx.agents in a plugin have one type, AgentActions: start(agent, input, options?), run(agent, input, options?), and group({ concurrency, onFailure, key }, (g) => [g.start(...)]). The input type comes from the input schema of the agent, and the result type from its output. group resolves to the results in order. With onFailure: 'collect', each entry is an AgentSettled ({ ok, value } or { ok, error }). The children of a group over its concurrency wait as queued.
    • Each start takes key, limits: { timeoutMs, maxAttempts }, budget: { maxTokens, maxCost }, and onModelError. A run over its budget fails with the code budget, 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 past limits.timeoutMs fails with the code timeout. A child never has more budget than its parent has left. onModelError is 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 to retries times (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. A ctx.agents start in a plugin tool that an agent run calls is a child of that run, so it takes no maxConcurrent slot and the run can await it. parent: ctx.context.run names the run, and parent: null starts a root run. A parent that 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 RunNode of one run tree (turn, agent, tool, or command), with its parent, its user, its limits, its budget, and its result. agentRuns() reads the tree, and AgentRunInfo has runId, kind, budget, result, and error. Each harness.usage record has the runId of its run, and an agent run that ends keeps the usage of the run and its children.
    • maxDepth and maxCalls count per tree, and maxConcurrent counts the root agent runs of the session. Over maxConcurrent, a root start waits as queued. The log keeps the counts, so a restart does not reset them. A queued start 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.harness too.
    • A run with runAgainAfterCrash: true that 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 with runAgainAfterCrash: true, with the same subagentRunId. durability.resumeBackgroundAgents: false fails it instead.
    • ctx.harness.state({ key, initial }) gives the agents of a session a shared state by key, with get and update. The log keeps it, and a fork copies it. STATE_SNAPSHOT has { plugins, shared }.
    • A routed agent takes steers at its next model call, and late joins in the same turn, and turn.beforeFinish and turn.onModelError run for it.
    • Every host keeps each session tree in one log, stores.log from @tanstack/ai-persistence. memoryPersistence() has a memory log, and a host with no persistence uses 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 the durability.logState fold of the whole tree. A harness with another durability.addRecordsToModelContext or durability.logState than 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, and promptCache). A command that reaches another host ends as it ends on the owner: with its result, its error, or InputRejectedError. An input to a thread that was deleted is refused: the receipt is rejected with the reason deleted, and an awaited operation rejects with InputRejectedError ("The thread was deleted."). ownership: { expiresAfterMs, renewEveryMs } sets the timing. renewEveryMs must be less than expiresAfterMs, else createHarnessHost throws a RangeError. With only expiresAfterMs, renewEveryMs is 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 with InputRejectedError. The log gets a harness.offer.rejected record. 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. updateThreadSettings merges into the latest stored settings.
    • The owner writes a snapshot of the folded log to the log store after log.saveSnapshotAfter.bytes of new records (default 1 000 000) or log.saveSnapshotAfter.ms (default 300 000), and a new host opens from it. With lifecycle.retention.keepLogRecords: 'since-snapshot' (default 'all'), the owner truncates the records before each snapshot. A reader whose cursor is in the dropped part gets one harness.gap event (EventGap), then the stream goes on. With 'since-snapshot', a thread whose snapshot the host cannot use (a changed reduce version or project) cannot open, and rejects with LogTruncatedError. session.revert() works with 'since-snapshot': the snapshot keeps the last 500 file steps. A plugin sets which of its records the snapshot keeps with keepRecords: { [type]: count } on its definition, and reads them, oldest first, with ctx.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.submit stores an input for a thread and runs nothing. session.schedule({ at, input, scheduleId, user }) runs an input later, and session.cancelSchedule({ scheduleId }) stops it. createHarnessWorker({ host, harnesses, concurrency, signal, pollMs }) runs the threads that have work and no live host, and returns { stop }. host.resumePending does 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.submit throws 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, so step.do passes { idempotencyKey, epoch } to fn. 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. A step.do value must be JSON: a Date, a Map, 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.truncatedToolResult sets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn.
    • turn.onModelError, retryTransientErrors(), turn.beforeFinish, and turn.canJoin control how a turn retries, continues, and ends.
    • Every approval, question, sign-in, client tool, and human takeover that a run waits for is a Wait in the session log (waitId, kind, runId, schema, expiresAt, message). WaitKind is approval, question, sign-in, client-tool, or human. waitKind, isApproval, waitFromInterrupt, and waitFromQuestion come from the root and from ./view. snapshot().pendingWaits lists the open waits: each one with its interrupt when a chat run stopped for it, and turn: true for a wait of the stopped turn. It is the one wait view of a snapshot. and session.waitForRun({ runId, signal }) resolves with the next one. A wait ends answered, cancelled, or expired. A wait with expiresAt expires on time. Only sign-in has 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. resolve answers the waits with an interrupt: of a stopped turn, or of one background agent run per call. answer answers the waits with no interrupt (a step.ask question, an MCP question, the human wait of a handoff), checks the answer against the JSON Schema of the wait, and keeps a secret out of the log. A resolve of such a wait is refused with use_answer, and a second answer to a wait that one answer ends is refused. host.submit checks an answer against the wait that it answers.
    • A run waits for a person only at fixed points: permissions() before a tool call, the question tool, step.ask in a durable tool, a command (CommandContext.step), or an agent run, and the live waits of MCP elicitation and OAuth sign-in. ctx.session.ask throws anywhere else, with an error that names step.ask, the question tool, and PermissionResources. A command that called step.ask runs again from the top after a host stop. Question takes expiresAt and signal.
    • A background agent run stops with status waiting on a wait, for example an approval of permissions(), 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, and ctx.harness.step.ask questions are waits of the turn in snapshot().pendingWaits, with agent and subagentRunId. 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. Its ctx.harness.step.do values are in the log. With durability.agentToolWaits: false, the agent does not wait: a call of it that needs an approval is refused.
    • The operator access action answers a human wait of any run, also with a resolve. A human wait takes only operator. membersOnly() gives it only to the role operator, so the user who waits for a person cannot act as that person. membersOnly({ operator: { roles } }) sets the roles that have it. isAllowed gets the wait (waitId, kind) of an answer or operator action.
    • session.settled({ inputId, signal }) takes one object. signal stops the wait for the settlement.
    • session.onQuestion, session.onAuthRequired, session.onMedia, and session.onUsage call 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), and harnessText() / harnessAgent() to use a harness as a text adapter or an agent. In a session, a harnessAgent child 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 in host.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, and host.open refuses 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 outer RUN_FINISHED. Its SSE reader takes multi-line data: and CRLF. harnessText() and harnessAgent() forward events on one shared path.
    • createHarnessHost({ observability: { tracer, onOperation } }) traces runs. tracer takes an OpenTelemetry Tracer. @opentelemetry/api is an optional peer dependency: the host loads it only when it has a tracer. The host writes harness.operation spans (turns, agent runs, commands), and harness.recovery, harness.wait, and harness.transfer spans. A child run on the same host is a child span: the host passes trace.setSpan(context.active(), parent) as the third argument of startSpan. A tracer that throws is logged and fails no run. SendOptions.traceparent stores a W3C traceparent with the input, and the run span on the host that runs it gets a link to it.
    • onOperation gets a started report and a settled report (OperationReport) for each operation that this host runs, with status, durationMs, error, and usage. A listener that throws gets a harness.plugin.warning event, 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, and harness.wait.settled. HarnessEventValues maps each harness.* event name to the type of its value.
    • onEvent, onQuestion, onUsage, and onMedia also get the events that another host writes to the log of the session. An event fires once. The status of host.events() counts that work too, for the sessions that are open on the host.
    • The router gets model: model.chat({ adapter, options, label }) in routing.router (HarnessRouterContext.model) calls a model with the keys of the sender, and a cancel of the turn stops it.
    • session.usage() has bySource: the model calls that are not a turn or an agent run, keyed by plugin:<name>, router, or transfer. Each UsageCall and harness.usage event of such a call has source, and label when the call has one. The summary of host.transfer with messages: 'summary' counts in the source thread under transfer, with the label summary, and stops after 2 minutes.

    Add first-party plugins. @tanstack/ai-harness/plugins loads 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 the default, plan, acceptEdits, and bypass modes. 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 is true or false, 'once', 'always', or 'reject', or { answer, message? }. It also has saved "always" rules with /permissions and /permissions forget <n>, and listSavedPermissions / deleteSavedPermission for server code. The rules apply in subagent runs too. It decides in onGuardToolCall, on the final checked arguments.
    • agents() (in plugins/node): agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, /agent to switch the primary agent, and the built-in build, plan, general, and explore agents. 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 profile model.
    • question(): a question tool for the lead turn. Each question is a wait of the turn, answered with resolve. Agent runs do not get the tool.
    • todos(), title(), modelPicker(), compact(), and usage() (with your own prices for calls that have no provider cost).
    • goal() runs each turn as the user who set the goal.
    • projectInstructions(), fileCommands(), and boundToolOutput(), in plugins/node.

    A plugin runs agents with ctx.agents.start, ctx.agents.run, and ctx.agents.group({ concurrency }, (g) => [g.start(agent, input)]), with the same types as ctx.harness.agents in agent code.

    PermissionRules, PermissionResources, PermissionDecisionCapability, and decidePermission are 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 after timeoutMs (default 2 minutes). Its usage counts in the session under plugin:<name> in session.usage().bySource. goal(), title(), and compact() call their models this way, with the labels judge, title, and compact.

    Serve a harness session to clients.

    • createHarnessHandler() from @tanstack/ai-harness/server serves a session over HTTP, as the user that the host access reads from each request. POST run is standard AG-UI, so useChat with fetchServerSentEvents works 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, and GET describe cover one session. sessions (with the share and unshare ops), GET host-events, and the media routes cover the rest. A host.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: getUser reads the user of the request, and that user needs read on the thread, in the tenant of the URL. For a request with no login, access.getGuestUser({ tenantId }) can give a guest, and isAllowed decides read for it. The guest exists only on the signed media route. requestUsers from @tanstack/ai-harness/server gives the user of each request with the same rule, for your own server. The read routes read a thread with host.observe, so they set up no plugins. GET describe opens the session.
    • handleHarnessSocket(), from the same entry, serves the same session over a WebSocket. It takes the upgrade request and reads the user from it. It handles the frames of one connection in order.
    • A command or an agent input over HTTP or the socket runs once for one inputId, and the inputId of its receipt is the id of the client.
    • expose on defineHarness() names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds gets rejected with not_exposed.
    • createHarnessClient() from @tanstack/ai-harness/client is a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor. Without from, 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 pass from: '0' or a saved cursor. When the snapshot answer has no string cursor, client.events() throws and does not retry. client.events() connects again after idleTimeoutMs (default 60 000) with no bytes. The prompt input of the protocol and client.send take as: 'operator' for the reply of an operator after a handoff to a person.
    • The client methods take one object: cancelInput, revert, agentTranscript, mediaUrl, appendRecords, and putMedia. client.sessions.list, rename, delete, update, fork, share, and unshare manage the sessions. HarnessErrorCode, the type of HarnessProtocolError.code, comes from the main, /client, and /server entries.
    • oauthConnector() and the OAuth helpers from @tanstack/ai-harness/oauth (loopbackLogin, deviceLogin, exchangeCode, refreshCredential, and more) keep a credential per user. A tool calls token({ run: ctx.context.run }) for the token of the user of its run. token({ run, wait: true }) stops the turn with an auth_required interrupt until the user signs in. deviceLogin, loopbackLogin, startLoopbackReceiver, exchangeCode, and refreshCredential(config, credential, { fetch, signal }) stop at once when their signal aborts, so a cancel or a session close stops a running /connect. OAuthConfig.issuer makes loopbackLogin refuse 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 with setIf when the store has getVersioned and setIf, and uses the other host's token on a conflict. Without that pair, it does a plain set. It saves to the scope where it found the credential, so a tenant token stays the tenant's. update gets a signal that only host.close() aborts. An abort of signal rejects only that call. oauthConnector refreshes this way, and refreshCredential keeps the client of 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 a harness.event frame, with cursor, threadId, and operationId. The AG-UI RUN_STARTED of a child agent run has parentRunId, so a client can rebuild the run tree.
    • The first frame of a stream is harness.hello with the version v. A client asks for a version with ?v= or with v in harness.subscribe. Another version gets a harness.error frame with the code unsupported_version, and the stream closes (socket code 4400).
    • A harness.error frame has a code (unauthorized, forbidden, invalid_frame, unsupported_version, slow_consumer, or internal) and a requestId. On the socket and the stdio worker, the requestId is the id of the frame that failed. Every answer of the handler has an X-Request-Id header: 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 : ping comment and the socket sends a harness.ping frame when idle. SSE reads the log only as fast as the client reads, and the socket waits while its bufferedAmount is over the limit. A client that does not read in time gets a slow_consumer error frame, and the stream closes (socket code 4408). A slow client loses no events: it connects again from its last cursor. createHarnessClient does this in events() and hostEvents(), and ends the read with a HarnessProtocolError (with its code) for any other error frame.
    • presence: { onConnect, onDisconnect } on createHarnessHost reports each client stream of GET events and of the socket (after its subscribe), as a PresenceEvent with a new connectionId, the user, the thread, the transport, and resumed. onDisconnect runs once per stream, with the reason closed, revoked, or error. The host does not wait for a hook, and a hook that fails only warns.
    • stream: { heartbeatMs, slowConsumerMs, sseQueueBytes, socketBufferBytes } on createHarnessHandler and handleHarnessSocket changes 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. HarnessStreamOptions is the type.
    • A reader whose events are gone gets one harness.gap event, with reason: 'truncated' (a truncate of the log) or 'evicted' (the memory feed of a session dropped them). The stream then goes on from resumeAt.
    • A W3C traceparent goes with a message: as the traceparent header of POST control and POST run, or as the traceparent field of a harness.input frame. ClientSendOptions.traceparent makes client.send send the header.
    • A cancel input stops a turn from any host that reads the same log, so client.cancel({ operationId }) works behind a load balancer. A closed connection still never cancels work. To stop a useChat turn, call client.cancel with the runId of useChat in the stop handler.

    Add createSessionView() in @tanstack/ai-harness/view. It turns a session (local, or remote through createHarnessClient()) 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 a status of running or waiting, media with signed URLs, waiting inputs, usage, models, settings, commands, and plugin state.
    • The waits of background agent runs show in approvals, clientTools, and interrupts. The view answers each one at once: with resolve, or with answer for a human wait 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 }), and putMedia take one object, and so does the reject({ payload? }) of an approval item.
    • Typed events: view.on('approval' | 'interrupt' | 'turnEnd' | ..., handler) and plugin events.
    • In a harness with routing, an assistant message has agent, 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.gap event, 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, and SessionViewEvents as @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 resume show and control it. The GoalMet event fires when the goal is met, and selectGoal(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 .env file. It adds /connect:<id>, /disconnect:<id>, and /keys. /connect:<id> runs the provider's signIn when it has one, else it opens keyUrl when set and asks the user to paste the key with step.ask and secret: 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 a keyedAdapter(...) for that provider uses it. /keys shows 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 without allowServerEnvKeys, no env var counts.

    Add coding tools in the new Node entry @tanstack/ai-harness/plugins/coding:

    • workspaceTools({ root }) gives read_file (text pages, images, PDFs), write_file, edit_file or patch (editStyle), list_files, grep, bash (with background), webfetch, and websearch (with a SearchProvider). With permissions(), edits, bash, and webfetch ask first, and the rules also apply to the real path of a link. outside: 'ask' makes a path outside root need an approval. permissions() decides outside paths and the real paths of links before the call runs, so a tool never asks while it runs. The cwd thread 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, reattach gives the workspace back (for example a resumed sandbox), or 'pinned' or 'gone'. A gone workspace sends tanstack/workspace-tools:stranded. With this plugin first, snapshots() and formatter() use the workspace of each thread, and their root is 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. Without hostId, threads are not pinned. A host with hostId sends a heartbeat to the log store, and its pins lapse when no heartbeat came for ownership.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 in base for a tenant and a thread id. It encodes each part, so a thread id from a client cannot leave base or reach another thread.
    • Commands, rg, git, and the formatters get only a short list of variables of the server process, plus the env of the backend call. Git runs without the hooks and the fsmonitor of the project.
    • The tools resolve links with realpath under 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 events tanstack/workspace-tools:job-started, job-output, and job-ended. A running command that session.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 no ps), 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() and jobs() of a host.observe view list the jobs that did not end (JobInfo: the job id, the thread, the command or tool name, who moved it, when it started, and running on this host). The bash spill file has [redacted] in place of each secret of the session.
    • read_file sends images and PDFs up to 256 KB. webfetch gives the model the first 256 KB of a page.
    • A WorkspaceBackend runs the tools. hostBackend is the default. realpath is required. spawn can give a jobId, exec can give one with onStart, and killJob, binding, and close are optional. killJob rejects 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 /undo and /redo, which restore only the files of the turn (git 2.26 or newer), file restore for session.revert(), and a diff for each model step. Each thread of each tenant has its own shadow repository, which never stores .env files. 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.delete removes the repository of the thread, and purgeSnapshots() does it from your own code. A failed snapshot sends tanstack/snapshots:failed.
    • formatter() formats each written file with the formatter of the project. With permissions(), 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. An always answer is tied to a hash of the formatter config files. A write of a formatter config below the root, or under node_modules, asks each time, and an always answer saves no rule for that formatter run.
    • WorkspaceHooks lets other plugins run code after a read or a write.

    @vscode/ripgrep and turndown are optional peer dependencies.

    @tanstack/ai-harness/build and @tanstack/ai-harness/worker: buildHarness bundles a harness with Bun into a worker artifact (harness.js and a harness.manifest.json with the sha256 digest), and can also build a single executable. artifactText(dir) checks the digest and uses the artifact as the model of a chat() call, with one worker process per thread. runHarnessWorker runs a harness on stdin and stdout with the session frames of the harness protocol.

    definePlugin refuses a setup() contribution, such as adapter or tools, at the top level of a definition, where it would be ignored. Return it from setup().

    write_file, edit_file, and patch send FileChanged (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, like bash, changed: at most 100 for each step. /undo, /redo, session.revert(), and session.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 run sends the log cursor of each event as its SSE id. A client that is cut off sends the same request with Last-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 of threadId, from its start or from Last-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 retried POST run) also streams from its start. Without threadId, only the instance that runs the turn finds it, and another instance answers 400. The join request of a ChatClient sends only the run id, so put threadId in the URL of the connection.
    • GET run?threadId= shows a turn that another instance runs as activeRun, so a reloaded useChat joins 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, with LogVersionError. A log snapshot from a newer harness is not used.

    createHarnessHost({ logger }) takes a Logger from @tanstack/ai for the warnings and errors of the harness and its chat() calls (default: ConsoleLogger). Plugins get it as ctx.logger, and a logger that throws does not stop the harness.

    PermissionCallCapability gives a plugin that runs tool calls itself, like code mode, the decision that permissions() makes for one call: allow, deny with the error that the model gets, or ask with the approval question. It uses the current mode, the saved always answers, and the resources of the call args, and settle(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 of run.keys (also from env vars) and run.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 a Map or a Set), 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.
    • scrubSecrets from @tanstack/ai-harness is the one from @tanstack/ai/byok.
    • A step.do value 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 outside step.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 onAfterToolCall hook (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: true question that reaches a host that does not own the log goes through the credentials store of the host, as a one-time reference with the credential type secret_answer and an expiresAt 24 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. Without createHarnessHost({ credentials }), the answer is rejected with secret_answer_needs_credentials. A used, expired, or unknown reference is rejected with unknown_secret_answer. credentials.list does not show the secret_answer type.

    Make the transport defaults safe for a server with many instances.

    • createHarnessHandler and handleHarnessSocket read the user of each request with the host access. On a host without access, pass openAccess: true (no user) or openAccess: { 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 of createHarnessHandler. 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 with ownership options needs media, else the handler throws at the start. On another host without media, 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.
    • oauthConnector needs login. 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 of agents()). 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 one harness.plugin.warning event that names both plugins.
    • createHarnessHandler reads each JSON body up to media.maxBodyBytes (default 32 MiB). A bigger body answers 413. handleHarnessSocket closes 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 example maxPayload of ws).
    • Client media is checked part by part before any part is stored, so a refused input stores nothing. A retried input with the same inputId stores its media no second time, and gets the first receipt.
    • A client input with a provider file handle (source.type: 'file') is refused with media_url_not_allowed unless the harness sets media.allowProviderFiles: true. Server code can still send file handles.
    • POST run moves the inline bytes of the last user message to the media store, as POST control does, so the transcript keeps a harness-media: reference. A refused part answers 413 (media_too_large), 415 (media_not_allowed), or 400 (media_invalid, media_url_not_allowed).
  • #1755 39c1c79 - Add computerUse() 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 keepScreenshots screenshots (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. Without permissions(), the call is refused.
    • The plan mode of permissions() refuses the computer tool.

    @tanstack/ai-harness is an optional peer dependency of @tanstack/ai-computer-use.

    A plugin can now store a file with ctx.session.putMedia(upload), and put mediaPart(record) in a tool result.

  • #1755 39c1c79 - createHarnessHost({ credentials }) takes the CredentialStore (from @tanstack/ai/byok) where users' API keys and OAuth sign-ins are saved. Without it, credentials stay in memory until the process stops. runCli takes the same credentials option.

  • #1883 39abb7e - Core support for the harness: subagent binding options, the onGuardToolCall hook, server-side BYOK keys, harness tool runs, checked streamed outputSchema results, 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 with RUN_ERROR structured-output-validation-failed, with the model text in metadata.tanstack.rawText.
    • session.send(prompt, { outputSchema }) gives the turn a typed structured value. It is per send only, and the HTTP handler does not take it.
    • A server tool reads run capabilities with context.get(capability) and context.getOptional(capability), for example the sandbox under withSandbox.
    • 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 with override: true wins over the stored thread model.
    • onModelError can return { retry: true, adapter } to retry on another model. ModelErrorContext has adapter and model.
    • Middleware reads ctx.context.run.source and label to tell a turn call from a plugin, router, or transfer call.
    • host.sessions.archive() and unarchive(). Lists hide archived threads unless you pass withArchived: 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 new erase access action guards it.
    • HarnessRecord is exported, for a typed LogStore wrapper.
    • Coding-tool file calls pass their abort signal through WorkspaceBackend to the sandbox.
    • withCompaction limits (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, and permissions() 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
      with pending_interrupts. meta is echoed as a tanstack.injection custom
      event. args is typed from the input schema of a harness tool.
    • expose.tools lists 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, and defineHarness throws for a name that is not in tools. Any
      other tool is refused with not_exposed. The capabilities document marks
      each tool with exposed.
    • expose.records lists the host record types a client may add with an
      { op: 'append', records } input, or with the new client.appendRecords({ records }).
      A record of another type, or with a thread field, is refused with
      not_exposed. The session log keeps the records.
    • createHarnessMcpServer adds a tool_<name> tool for each tool in
      expose.tools, and an append tool when expose.records has types. A tool
      input with its own threadId field throws when the server is created,
      because MCP uses threadId to 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 threadId and runId in its execution
      context. ToolExecutionContext in @tanstack/ai has the two new optional
      fields for a host to set.

    A host can add fields to the ctx an agent's run gets.

    • subagents.binding.extend(ctx) returns the fields to add. A child's ctx.chat({ subagents }) passes it down, so the fields reach nested children too.
    • SubagentContextExtension is an empty interface that SubagentRunContext includes. A package augments it to type its fields, for example ctx.harness from @tanstack/ai-harness. Make each field optional, because a run that a plain chat() starts has no host.

    Add interrupts to SubagentBinding.

    • A host sets binding.interrupts to give interrupt definitions to every ctx.chat call of an agent run. Then the host middleware in chatMiddleware can stop the child with these interrupts.
    • The own interrupts of 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 lastChatMiddleware to SubagentBinding. It runs after the own middleware of each ctx.chat call of an agent run, so it sees the final model context and tool results. A child's ctx.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 the StandardSchemaV1 and StandardJSONSchemaV1 types.

    An agent run that throws the shape of MCPInputRequiredError (name, kind, request) stops the child as suspended, 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 the interruptId of the thrown value, else mcp_input_<subagentRunId>, and its message is the error message. The resume runs the child again, and ctx.resume has the answer.

    Add SubagentBinding.debug, the debug option for the model calls of child agents that set no debug of their own.

    Add the onGuardToolCall middleware hook for checks that must always run, such as permissions.

    • It runs for every tool call, after all onBeforeToolCall hooks and after the inputSchema check. hookCtx.args holds the final checked arguments that the tool gets.
    • The guard of every middleware runs, in order, until one returns a decision. An earlier onBeforeToolCall decision does not skip it.
    • Return { type: 'skip', result } to deny the call. The tool does not run, and the model gets result, the same as an onBeforeToolCall skip. 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 GuardToolCallDecision type is exported.

    BYOK keys can now live on the server. @tanstack/ai/byok exports the CredentialStore contract, the Credential and CredentialScope types, and defineCredentialStore. A CredentialScope has tenantId and userId. Its threadId is optional, and a store ignores it. @tanstack/ai/byok/server adds:

    • storedProviderKeys(store, scope): provider keys from the api_key credentials of a user, then of the tenant, then the provider's env names. Pass keys.adapter(...) to chat() and keys.get to subagents.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, or undefined when the scope has no user or no tenant.
    • memoryCredentialStore(): a store in memory, for development and tests.
    • envKeyEntry(provider): the first provider.env name that is set, with its value.
    • readCredentialEntry(store, scope, id): reads a credential like readCredential. It also tells you which scope had the credential (the user or the tenant), and gives its revision when the store has getVersioned.

    CredentialStore has two optional methods for compare-and-set writes, getVersioned and setIf, with the same shape as the pair on MetadataStore. A store without them still works. memoryCredentialStore has both.

    Keep the store in your own database or in a secrets manager, and encrypt the secrets at rest. An oauth credential can keep the OAuth client it was issued to. list never returns secret values.

    scrubSecrets from @tanstack/ai/byok now 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.

    Credential has 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().list returns its expiresAt.

    Approved edits to tool arguments stay raw until the final input check.

    • @tanstack/ai: a resume no longer rejects approved editedArgs that do not fit the tool's inputSchema. The edit goes through onBeforeToolCall and 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: withPersistence binds 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. LoadChild takes an optional parentSubagentRunId, and returns interruptIds and parentToolCallId.

    chat() asks the middleware onShouldContinue after it runs the pending tool calls of a resume, before the next model call. A middleware that returns false there ends the run with no more model calls. The loop strategy is not asked at that point.

    WebSocketLike has an optional bufferedAmount. 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 completed with no answer.
    • Approvals: a tool that a middleware adds in onConfig can now be approved. chat() checks a durable resume after the whole onConfig chain.
    • Cancel: a cancelled turn keeps the text it streamed, and ends with TEXT_MESSAGE_END and RUN_FINISHED (outcome: cancelled). A turn that times out ends with RUN_ERROR (code: 'timeout').
    • Steer: a steer from another sender gets a queued receipt with its own operation. host.events() shows idle after a turn that a steer joined.
    • The duplicate-input check no longer depends on the key order of the stored input.
    • TurnInfo has model, attempt, and sender. The log keeps the sender's name. harness.input.accepted carries the message. The chat hydration has pendingWaits. ObservedSession.settled() reads a settlement with no open session. Another host's snapshot().plugins shows the saved plugin state.
    • New options: subagents.background: false, media.urlTtlMs, conversationSummarizer({ middleware }), and the beforeWrite workspace hook, which can refuse a write.
    • Plugin model calls (ctx.model.chat) run the harness middleware.
    • Exports: SHUTDOWN_REASON, BUDGET_REASON, and RUN_CANCEL_REASON from @tanstack/ai-harness, HARNESS_EVENTS from @tanstack/ai-harness/client, and ChatHydrateOptions and ChatHydrationResult from @tanstack/ai-client.
    • Compaction drops a background summary that another model prepared.
    • Sandbox file calls take an optional { signal }.
    • Anthropic: outputSchema moves keywords that Anthropic rejects into description, usage reports 1-hour cache writes as cacheWrite1hTokens again, and reasoning on 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 its msg_ and fc_ ids.
    • Cloudflare: a data: line with no choices no longer throws, and the SSE fix-up accepts data: 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 code structured-output-parse-failed.
    • ChatClient.addToolResult answers 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.
  • 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