Skip to content
Harness

Build your first harness

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.

Install

shell
npm i @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openai

Which import to use

Import 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:

ImportHolds
@tanstack/ai-harnessdefineHarness, createHarnessHost, definePlugin, durableTool, harnessText, oauthConnector, and the session and agent types.
@tanstack/ai-harness/servercreateHarnessHandler and handleHarnessSocket. For your own transport: applyInput, parseHarnessInput, parseControlFrame, capabilitiesOf, and HARNESS_PROTOCOL_VERSION.
@tanstack/ai-harness/clientcreateHarnessClient, a typed client for a browser or another process.
@tanstack/ai-harness/viewcreateSessionView, the live state of a session for a UI.

For plugins:

ImportHolds
@tanstack/ai-harness/pluginsThe first-party plugins that load no Node module: permissions, modelPicker, todos, goal, providerKeys, and more.
@tanstack/ai-harness/plugins/nodeThe first-party plugins that read files. Node only: agents, builtInAgents, fileCommands, projectInstructions, and boundToolOutput.
@tanstack/ai-harness/plugins/codingThe coding tools. Node only: workspaceTools, snapshots, and formatter.

For helpers and builds:

ImportHolds
@tanstack/ai-harness/oauthThe OAuth helpers: loopbackLogin, deviceLogin, exchangeCode, refreshCredential, and more.
@tanstack/ai-harness/mediaThe media helpers: mediaPart, mediaOfMessage, MediaError, and more.
@tanstack/ai-harness/buildbuildHarness and artifactText, to ship a harness as a worker. Node only.
@tanstack/ai-harness/workerrunHarnessWorker, which runs a harness on stdin and stdout. Node only.

1. Define the harness

defineHarness takes the chat() options you know (adapter, systemPrompts, tools, middleware, subagents), plus a name. Importing it starts nothing.

ts
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.

2. Open a session

A host runs sessions. Give it the stores from @tanstack/ai-persistence, then open a session for a conversation id.

ts
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.

3. Send a message

send returns an operation. Await it for the text of the turn.

ts
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:

ts
const next = session.send('Now one about the mountains.')
next.onEvent('TEXT_MESSAGE_CONTENT', ({ event }) => {
  process.stdout.write(event.delta)
})
await next

The 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.

4. Send messages while it works

Users type while the agent works. Pick what a new message does:

  • session.send(text): runs after the current turn. This is the default.
  • session.send(text, { busy: 'reject' }): refuses the message while a turn runs.
  • session.send(text, { mode: 'steer' }): adds the message to the running turn, before its next model call. The operation ends with that turn.
  • session.send(text, { mode: 'followUp' }): runs after all current work.

Each call returns an operation. await operation.receipt resolves when the session stored the message.

ts
const long = session.send('Plan a three-day trip to Lisbon.')
await session.send('Keep it under 500 euros.', { mode: 'steer' })
await long

If the turn makes no further model call, the steer message runs as the next turn.

5. Run an agent from code

Every agent in agents and subagents is on session.agents. The input and the result are typed from the definition.

ts
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:

ts
session.agents.pricer.start({ vendor: 'globex' }, { startTurnWhenDone: true })

6. Add a plugin

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.

ts
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.

Keep sessions across restarts

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:

  • log: the session log. Required.
  • sessions: one index entry per thread, for a list of sessions. See List, rename, and fork sessions.
  • metadata: data wider than one thread, for example the saved rules of permissions().

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.

Prompt caching

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:

ts
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:

ts
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.

Set a reasoning level

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:

ts
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:

  • level: off, minimal, low, medium, high, xhigh, or max. A model that does not have the level gets the nearest level that it has.
  • summary: true streams the thinking text. false keeps it out of the stream.
  • budgetTokens: optional. A thinking token budget, for models that think with a budget.

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.

What you have now

  • A harness defined once, with the same options as chat().
  • A session that keeps the conversation and streams AG-UI events.
  • Queue, steer, and follow-up for messages that arrive during a turn.
  • Typed agents you run from code, in the foreground or the background.
  • Plugins that add tools and prompts to each session.

Next: open Run agents from a harness to start agents from plugins and let the model call them as tools.