A model can stop before the job is done: it writes a nice answer and forgets to call post_message. A provider can answer 503 in the middle of a turn. A user can send three messages while the agent works, and one of them must not join. The turn options of the harness give you a hook for each case.
One job can also need its own model or tools. Pass overrides to the prompt to give it its own settings. Two tools of one model call can need to run one after the other. Set toolExecution on the harness.
The beforeFinish hook runs when the model stops calling tools. Check the transcript. If the job is not done, return a message. The model runs again in the same operation.
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
turn: {
beforeFinish: ({ messages }) => {
const posted = messages.some((message) =>
message.toolCalls?.some((call) => call.function.name === 'post_message'),
)
if (posted) return undefined
return {
messages: [
{
role: 'user',
content: 'You have not called post_message yet. Call it now.',
},
],
}
},
},
})A reminder in messages stays in the transcript, so every later model call sees it again. Return it as ephemeral, and only the next model call gets it:
const reminded = defineHarness({
name: 'acme/reminded',
adapter: openaiText('gpt-6.1-sol'),
turn: {
beforeFinish: ({ messages }) => {
const done = messages.some((message) =>
message.toolCalls?.some((call) => call.function.name === 'finish'),
)
if (done) return undefined
return {
ephemeral: [{ role: 'user', content: 'Call finish or give_up now.' }],
}
},
},
})A model call can fail for a reason that passes in a few seconds: an overloaded provider, a rate limit, a dropped connection. Set onModelError to retryTransientErrors(). The harness runs the failed call again in the same operation.
import { retryTransientErrors } from '@tanstack/ai-harness'
const resilient = defineHarness({
name: 'acme/resilient',
adapter: openaiText('gpt-6.1-sol'),
turn: { onModelError: retryTransientErrors() },
})The defaults:
Pass your own check when you know more about your provider:
import { isTransientModelError } from '@tanstack/ai-harness'
const picky = defineHarness({
name: 'acme/picky',
adapter: openaiText('gpt-6.1-sol'),
turn: {
onModelError: retryTransientErrors({
maxRetries: 5,
baseDelayMs: 500,
isTransient: (error) =>
error.code === 'upstream_busy' || isTransientModelError(error),
}),
},
})The OpenAI and Anthropic adapters set retryAfterMs from the retry-after-ms or retry-after header of the provider response. A hook of your own gets it as error.retryAfterMs.
Clients get a harness.turn.retry event for each retry. Its value has operationId, retries, error, and continued.
A stream can fail after the model wrote half of its answer. A retry drops that half, and the model writes the full answer again. Answer 'continue' from onModelError to keep it:
const continuing = defineHarness({
name: 'acme/continuing',
adapter: openaiText('gpt-6.1-sol'),
turn: {
onModelError: ({ error, retries, partial }) => {
if (retries >= 3 || !isTransientModelError(error)) return undefined
return partial ? 'continue' : 'retry'
},
},
})When the hook answers 'continue':
partial is true when the failed call streamed text, reasoning, or a tool call before the error. Only text can be continued. When no text streamed, 'continue' acts as 'retry', and the failed call adds nothing to the turn result.
A provider can stay down for longer than your backoff. Return { retry: true, adapter } from onModelError to run the retry on another model:
import { anthropicText } from '@tanstack/ai-anthropic'
const backup = anthropicText('claude-sonnet-5-5')
const failover = defineHarness({
name: 'acme/failover',
adapter: openaiText('gpt-6.1-sol'),
turn: {
onModelError: ({ error, retries, adapter }) => {
if (retries >= 3 || !isTransientModelError(error)) return undefined
if (adapter === backup) return 'retry'
return { retry: true, adapter: backup }
},
},
})The answer acts as 'retry': the text of the failed call is dropped. A recovery after a host stop runs the turn on its own model.
A model call can fail because the conversation is longer than the context window of the model. Add the compaction middleware to the harness. It handles the overflow itself:
import { withCompaction } from '@tanstack/ai-compaction'
const compacting = defineHarness({
name: 'acme/compacting',
adapter: openaiText('gpt-6.1-sol'),
middleware: [withCompaction({ maxTokens: 100_000, countTokens: 'usage' })],
turn: { onModelError: retryTransientErrors() },
})To keep the compaction after a restart, see Compact a harness session.
A message with busy: 'steer' waits while a turn runs. Before each model call, the waiting messages join the turn as a prefix, in the order they arrived.
The join stops at the first message that:
That message and the ones after it wait. canJoin runs again before each model call and at each final answer, so it can run more than once for one message. Keep it a quick check with no side effects. What still waits when the turn ends runs as its own turn, before other queued turns. A cancelled one settles aborted and does not run.
A session.runTool() turn takes no messages. It runs no model, so nothing could answer them. A message that arrives while it runs waits, and then runs as its own turn. See Run a tool without a model.
A turn that routing sends to root agents takes messages, the same as the main model. The root agents get them at each model call.
A joined message runs with the credentials of the sender of the turn. So without turn.canJoin, a waiting message joins the running turn only when the same person sent both. A message from another person waits and runs as its own turn after the running one, as its sender. Joins are a prefix, so it also holds back the messages that wait after it. Set canJoin to choose your own rule. Compare user with turnUser to keep one person's message out of another person's turn. See Keep other people out of a running turn.
onJoin runs before the model call that the joined messages reach. It can return messages, which come after the joined ones, and records, which land in the same append as the join.
const selective = defineHarness({
name: 'acme/selective',
adapter: openaiText('gpt-6.1-sol'),
turn: {
canJoin: ({ message }) =>
typeof message === 'string' && message.length <= 500,
onJoin: ({ inputs }) => ({
messages: [
{
role: 'user',
content: `${inputs.length} new message(s) arrived. Answer them before you continue.`,
},
],
records: [{ type: 'app.join', count: inputs.length }],
}),
},
})Tools or system prompts that a plugin or a middleware adds during a turn keep the prompt cache on models with a mid-conversation channel. See Mid-conversation changes.
The model can call several server tools in one model call. By default, they run at the same time. Some tools must not overlap, for example a tool that writes a file and a tool that runs the tests. Set toolExecution: 'sequential' on the harness:
import { toolDefinition } from '@tanstack/ai'
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
const writeFile = toolDefinition({
name: 'write_file',
description: 'Write a file',
inputSchema: z.object({ path: z.string(), content: z.string() }),
}).server(async ({ path }) => ({ written: path }))
const runTests = toolDefinition({
name: 'run_tests',
description: 'Run the test suite',
inputSchema: z.object({}),
}).server(async () => ({ passed: true }))
const coder = defineHarness({
name: 'acme/coder',
adapter: openaiText('gpt-6.1-sol'),
tools: [writeFile, runTests],
toolExecution: 'sequential',
})| Value | What it does |
|---|---|
| 'parallel' (default) | Prepares each call in call order: the input check, the approval check, and onBeforeToolCall. Then the server tools start together. |
| 'sequential' | Runs one call at a time, in call order. Each call finishes onBeforeToolCall, its run, and onAfterToolCall before the next call starts. |
With both values, the model gets the results in the order of its calls. Client tools do not change.
Use 'sequential' when:
Keep 'parallel' for slow tools that do not depend on each other. Then a model call with three web fetches takes as long as the slowest fetch.
The setting applies to every turn of the harness. overrides cannot change it for one prompt. To set it on chat() without a harness, see Run tools one at a time.
Most prompts are fine with the harness settings. One job can need more: a stronger model for a review, more reasoning, or a tool that only this job uses. Pass overrides to session.send:
import { toolDefinition } from '@tanstack/ai'
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
const readDiff = toolDefinition({
name: 'read_diff',
description: 'Read the diff of the open pull request',
}).server(async () => 'diff --git a/src/cart.ts b/src/cart.ts')
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(resilient, { threadId: 'thread-1' })
const review = await session.send('Review the open pull request.', {
overrides: {
adapter: anthropicText('claude-opus-5-5'),
reasoning: 'high',
tools: [readDiff],
},
})
console.log(review.text)The overrides apply to this turn only. The next prompt without overrides uses the harness settings again. The type is TurnOverrides, from @tanstack/ai-harness.
| Field | What it does in this turn |
|---|---|
| adapter | Replaces the harness adapter and every plugin pick. A keyed adapter uses the key of the user. |
| reasoning | Replaces the reasoning of the harness. |
| promptCache | Replaces the session and harness values, field by field. { key: 'review' } keeps the session retention. |
| tools | Adds tools to the tools of the harness and the plugins. |
Every model call of the turn uses the overrides:
The promptCache of the overrides also reaches the agents that the model calls as tools in this turn.
The session log keeps the JSON overrides: model, modelOptions, reasoning, and promptCache. adapter and tools are objects, so they stay in memory only.
To keep a model or instructions for every turn of a thread, also after a restart, store them with session.configure. See Store settings per thread.