chat() answers one request. A coding agent, a support bot, or a media studio needs more: one conversation that stays open, takes new messages while it works, and runs other agents from code. A harness gives you that. You define it once with the same options as chat(), then open a session for each conversation.
At the end of this page you have a session that answers prompts, queues and steers messages, runs a typed agent, and loads a plugin.
Warning
The harness is experimental. @tanstack/ai-harness and @tanstack/ai-harness-cli are on 0.x, and a minor release can change the API. Pin the exact version you install.
npm i @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaiImport each name from the entry that holds it. Only four entries load Node modules: plugins/node, plugins/coding, build, and worker. All other entries also load on an edge runtime. One exception at run time: the loopback helpers of oauth (startLoopbackReceiver and loopbackLogin) start a local Node server, so call them only on Node.
For your app and its routes:
| Import | Holds |
|---|---|
| @tanstack/ai-harness | defineHarness, createHarnessHost, definePlugin, durableTool, harnessText, oauthConnector, and the session and agent types. |
| @tanstack/ai-harness/server | createHarnessHandler and handleHarnessSocket. For your own transport: applyInput, parseHarnessInput, parseControlFrame, capabilitiesOf, and HARNESS_PROTOCOL_VERSION. |
| @tanstack/ai-harness/client | createHarnessClient, a typed client for a browser or another process. |
| @tanstack/ai-harness/view | createSessionView, the live state of a session for a UI. |
For plugins:
| Import | Holds |
|---|---|
| @tanstack/ai-harness/plugins | The first-party plugins that load no Node module: permissions, modelPicker, todos, goal, providerKeys, and more. |
| @tanstack/ai-harness/plugins/node | The first-party plugins that read files. Node only: agents, builtInAgents, fileCommands, projectInstructions, and boundToolOutput. |
| @tanstack/ai-harness/plugins/coding | The coding tools. Node only: workspaceTools, snapshots, and formatter. |
For helpers and builds:
| Import | Holds |
|---|---|
| @tanstack/ai-harness/oauth | The OAuth helpers: loopbackLogin, deviceLogin, exchangeCode, refreshCredential, and more. |
| @tanstack/ai-harness/media | The media helpers: mediaPart, mediaOfMessage, MediaError, and more. |
| @tanstack/ai-harness/build | buildHarness and artifactText, to ship a harness as a worker. Node only. |
| @tanstack/ai-harness/worker | runHarnessWorker, which runs a harness on stdin and stdout. Node only. |
defineHarness takes the chat() options you know (adapter, systemPrompts, tools, middleware, subagents), plus a name. Importing it starts nothing.
import { defineAgent } from '@tanstack/ai'
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const pricer = defineAgent({
name: 'pricer',
description: 'Looks up the price of a vendor plan',
inputSchema: z.object({ vendor: z.string() }),
run: async (ctx) => ({ vendor: ctx.input.vendor, cents: 1200 }),
})
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
systemPrompts: ['You are a helpful assistant.'],
agents: [pricer],
})agents are agents you run from code. Put an agent in subagents instead when the model must call it as a tool, the same as in chat().
To ship the harness to users with no .env file, let each user connect their own key. See Connect model providers.
A host runs sessions. Give it the stores from @tanstack/ai-persistence, then open a session for a conversation id.
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(assistant, { threadId: 'thread-1' })Opening the same threadId again gives another handle to the same live session. Release a handle with await session.close(), or open it with await using, so an idle session can close. See Lifecycle and retention.
Does the host serve many users? Give it access, so each user reaches only their own sessions. See Access and tenants.
To see every option of createHarnessHost, and which ones your app needs, see Host options.
send returns an operation. Await it for the text of the turn.
const turn = await session.send('Write a haiku about the sea.')
console.log(turn.text)The session saves the transcript, so the next message continues the same conversation.
Want the words as they arrive? Give the operation a listener for the event type you care about:
const next = session.send('Now one about the mountains.')
next.onEvent('TEXT_MESSAGE_CONTENT', ({ event }) => {
process.stdout.write(event.delta)
})
await nextThe events are standard AG-UI events, and event has the type you asked for. To listen to every operation of the session, see React to events.
Users type while the agent works. Pick what a new message does:
Each call returns an operation. await operation.receipt resolves when the session stored the message.
const long = session.send('Plan a three-day trip to Lisbon.')
await session.send('Keep it under 500 euros.', { mode: 'steer' })
await longIf the turn makes no further model call, the steer message runs as the next turn.
Every agent in agents and subagents is on session.agents. The input and the result are typed from the definition.
const price = await session.agents.pricer.run({ vendor: 'acme' })
console.log(price.cents)The session adds a short note about the result, or the error, to the transcript. The model sees it on the next turn. To run the agent in the background and start a new turn when it finishes or fails, use start:
session.agents.pricer.start({ vendor: 'globex' }, { startTurnWhenDone: true })A plugin adds tools, prompts, middleware, and agents to every session. setup runs once when the session opens. Resources you acquire close when the session closes.
import { toolDefinition } from '@tanstack/ai'
import { definePlugin } from '@tanstack/ai-harness'
const clock = definePlugin({
name: 'acme/clock',
setup: () => ({
prompts: [`Today is ${new Date().toDateString()}.`],
tools: [
toolDefinition({
name: 'current_time',
description: 'Get the current time',
}).server(async () => new Date().toISOString()),
],
}),
})
export const withClock = defineHarness({
name: 'acme/assistant-with-clock',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [clock],
})plugins is a function, so every session gets its own plugin instances. Set lifetime: 'turn' on a plugin to set it up again for each turn, or lifetime: 'host' to share one setup between all the sessions of the host.
If two plugins add a tool with the same name, host.open fails and names both plugins. To write your own, see Write a harness plugin.
Each host keeps every thread in a session log: the transcript, the inputs, the runs, the approvals that wait, the settings, and the plugin state. The host needs one store for it, log:
memoryPersistence() has every store, but it keeps them in memory only. To keep the log in your database, implement the LogStore contract. Then a turn continues after a crash or a deploy. See Durable sessions.
Before you run many servers or long threads, read the known limits.
Every session caches the stable start of its requests by default, with its threadId as the cache key. To change the default for every session, set promptCache on the harness:
export const longCache = defineHarness({
name: 'acme/long-cache',
adapter: openaiText('gpt-6.1-sol'),
promptCache: 'long',
})To change it for one session, pass promptCache to host.open. The session value wins over the harness value:
const support = await host.open(assistant, {
threadId: 'thread-2',
promptCache: { key: 'acme-support' },
})If the threadId already has a live session, host.open gives a handle to that session, which keeps its first promptCache value. The agents that the model calls as tools use the retention of the turn. session.usage() shows the cache reads and writes, see Track usage and cost. For the options and the cost, see Prompt Caching.
To change it for one prompt, pass overrides.promptCache. See Give one prompt its own settings.
A harness sends no reasoning level by default, so the main model thinks at the default of its provider. To set a level for every turn, set reasoning on the harness:
export const careful = defineHarness({
name: 'acme/careful',
adapter: openaiText('gpt-6.1-sol'),
reasoning: 'medium',
})reasoning is a level, or an object with these fields:
For the levels of each model, see Reasoning. To use another level for one prompt, pass overrides.reasoning. See Give one prompt its own settings.
Next: open Run agents from a harness to start agents from plugins and let the model call them as tools.