Your agent needs a person before it can go on: a yes for a refund, the amount, a sign-in. A person can take a minute or a day. In that time the server can restart, and the answer can reach another instance. In a harness, each of these is a wait. The session log keeps it, so no process holds it, any instance can take the answer, and it can expire.
Call step.ask in a durable tool. This tool asks for the amount of a refund, then pays it once:
import { toolDefinition } from '@tanstack/ai'
import { createHarnessHost, defineHarness, durableTool } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { z } from 'zod'
declare const payments: {
refund: (orderId: string, amount: number) => Promise<{ id: string }>
}
const refund = durableTool(
toolDefinition({
name: 'refund',
description: 'Refund an order',
inputSchema: z.object({ orderId: z.string() }),
}),
async ({ orderId }, { step }) => {
const amount = await step.ask({
name: 'amount',
message: `How much do we refund for ${orderId}?`,
schema: z.number().positive(),
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
})
const paid = await step.do(`refund:${orderId}`, () =>
payments.refund(orderId, amount),
)
return { refundId: paid.id }
},
)
const support = defineHarness({
name: 'acme/support',
adapter: openaiText('gpt-6.1-sol'),
tools: [refund],
})When the host stops while the tool waits, the next host runs the tool again from the top. step.ask then returns the logged answer, or waits again. A finished step.do returns its stored value, so the refund runs once.
session.waitFor resolves with the first wait of a run:
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(support, { threadId: 'ticket-7' })
const turn = session.send('Refund order A-17.')
const wait = await session.waitForRun({
runId: turn.id,
signal: AbortSignal.timeout(60_000),
})
console.log(wait.kind, wait.message) // question How much do we refund for A-17?Each wait has a kind:
| Kind | The run waits for |
|---|---|
| approval | A yes or a no for a tool call. |
| question | An answer that fits the schema of the wait: from step.ask, the question tool, or an MCP server. |
| sign-in | The user signs in to a connector. |
| client-tool | The output of a tool that runs in the client. |
| human | A person who takes over, from an interrupt of defineInterrupt. |
waitKind(interrupt) and isApproval(interrupt) give the kind of the interrupt of a wait too. Import them from @tanstack/ai-harness.
const hasInterrupt = session
.snapshot()
.pendingWaits.some(
(open) => open.waitId === wait.waitId && open.interrupt !== undefined,
)
if (hasInterrupt) {
await session.resolve({
resume: [{ interruptId: wait.waitId, status: 'resolved', payload: true }],
})
} else {
await session.answer({ questionId: wait.waitId, value: 40 })
}
await turnThe tool pays 40 and the turn finishes. Two inputs answer waits:
Both are inputs. An answer that reaches another instance waits in the log until the owner takes it. host.submit stores an answer for a thread that has no open session.
Each wait ends with one outcome:
Only a sign-in wait has a default time limit: 15 minutes. The other kinds wait until someone answers, unless you set expiresAt. An interrupt of a turn with an expiresAt also ends on time.
session.waitFor and session.settled take a signal too. It stops your own wait for the session. It does not end the wait of the run.
A run waits for a person only at these points:
ctx.session.ask in your own plugin throws an error that names these points. A command that called step.ask runs again from the top after a host stop, so put each side effect before the ask in step.do.
On a host with access, its rules decide each answer:
To show waits in your own screen, see Build your own UI.