A network retry can send the same prompt twice, and a client that reconnects does not know how its last prompt ended. Give each input your own id. A retry with the same id runs nothing again, and the session tells you how the input ended.
On the server, pass inputId to send. await turn.receipt resolves when the session stored the input.
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(assistant, { threadId: 'user-1-thread' })
const turn = session.send('Summarize the report.', { inputId: 'req-42' })
const receipt = await turn.receipt
console.log(receipt.status) // 'accepted' or 'queued'The client sends the same inputId again after a network error. The second call gets the first receipt.
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { AnyHarness } from '@tanstack/ai-harness'
const client = createHarnessClient<AnyHarness>({
url: '/api/harness',
threadId: 'user-1-thread',
})
const send = () => client.send('Summarize the report.', { inputId: 'req-42' })
const receipt = await send().catch(send)
console.log(receipt.operationId)Make the id once per user action (a button press), and keep it for every retry of that action.
This works for every input over HTTP and the socket. A command or an agent input that the client sends again with the same inputId also runs once. The inputId of its receipt is the id that the client sent.
On the server, session.settled({ inputId }) waits until the input ends. It reads the log, so on a durable host it also works after a restart and on another host that opens the thread.
const settlement = await session.settled({
inputId: 'req-42',
signal: AbortSignal.timeout(60_000),
})
if (settlement.outcome === 'failed') {
console.log(settlement.error?.code) // 'attempts_exhausted', 'timeout', or undefined
}signal is optional. When it aborts first, settled rejects with its reason. The input keeps running.
On a host that does not own the thread, the input waits for the owner. If the owner refuses it, settled rejects with InputRejectedError. The owner refuses an input when the thread is busy and the input has busy: 'reject', or when the log has its id with another message. The log then has a harness.offer.rejected record.
If the turn fails after an approved tool ran (for example, the next model call fails), the interrupt is used up, and the tool does not run again. Its result stays in the transcript. To go on, send a new message. If a resume stopped before its tool ran, the interrupt stays open, and you can resolve it again.
A client gets the same answer as a harness.input.settled event:
for await (const entry of client.events()) {
const { event } = entry
if (event.type === 'CUSTOM' && event.name === 'harness.input.settled') {
console.log(event.value)
}
}Sometimes the transcript already holds what the model must answer, for example a tool result that your own system stored. Start a turn with continueWithoutMessage, and the model answers the stored transcript. No new user message goes in:
const continued = session.continueWithoutMessage({ inputId: 'req-43' })
await continuedFrom the client:
const continuedReceipt = await client.continueWithoutMessage({ inputId: 'req-43' })A reminder such as "call finish or give_up" helps the model in one turn, but it does not belong in the transcript. Pass it as ephemeral to send or continueWithoutMessage:
const reminded = session.continueWithoutMessage({
inputId: 'req-44',
ephemeral: [{ role: 'user', content: 'Call finish or give_up now.' }],
})
await remindedFrom the client:
const remindedReceipt = await client.send('Summarize the report.', {
inputId: 'req-45',
ephemeral: [{ role: 'user', content: 'Answer in three bullets.' }],
})To add a note from a hook when the model stops, see Remind the model without saving the reminder.
The model needs standing context for a turn: the team's memory, the tenant's rules, or the user's plan. It is not a user message, and it should not go in the transcript. Pass systemPreamble to session.send. The strings go in ahead of the harness's own system prompts, for this one turn.
const drafted = session.send('Draft a reply to the ticket.', {
inputId: 'req-50',
systemPreamble: [
'Team memory: keep the tone terse.',
'Tenant rule: never promise a refund.',
],
})
await draftedPass outputSchema to session.send. The turn's chat() call gets the schema, and the result has the checked object as structured, typed from the schema.
import { z } from 'zod'
const Ticket = z.object({
title: z.string(),
priority: z.enum(['low', 'high']),
})
const triaged = await session.send('Triage: the login page is down.', {
inputId: 'req-60',
outputSchema: Ticket,
})
console.log(triaged.structured?.priority) // 'low' | 'high' | undefinedIf the object fails the schema, the turn fails:
The code is structured-output-validation-failed (the JSON parsed, but the object failed the schema) or structured-output-parse-failed (the text was not JSON). rawText is the text of the model.
Where the schema applies:
With busy: 'steer' or mode: 'steer', a message joins the running turn. It reaches the model at the next model call, in the order the messages arrived, and it ends with that turn.
const running = session.send('Plan the migration.', { inputId: 'req-43' })
const joined = session.send('Keep it under five steps.', {
busy: 'steer',
inputId: 'req-44',
})
// One turn answers both, so both get its result.
await Promise.all([running, joined])
console.log((await session.settled({ inputId: 'req-44' })).operationId === running.id) // trueA message that arrives while the model writes its final answer still gets an answer in the same turn.
Waiting steer inputs join before each model call, in the order they arrived. They join as a prefix: the join stops at the first input that has an abort request, or that turn.canJoin refuses. The inputs after it wait.
To choose which inputs join, or to add records when they join, see Choose which messages join a turn.
A user sends two more messages while a turn runs. Before they run, the user wants to cancel one, and wants the other to reach the running turn now. session.waitingInputs() lists the inputs that wait. cancelInput cancels one, and changeInputDelivery moves one:
const migration = session.send('Plan the migration.', { inputId: 'req-45' })
// The turn runs, so these two wait for their own turns.
const tests = session.send('Then write the tests.', { inputId: 'req-46', mode: 'followUp' })
const style = session.send('Use tabs, not spaces.', { inputId: 'req-47', mode: 'followUp' })
await Promise.all([tests.receipt, style.receipt])
for (const input of session.waitingInputs()) {
console.log(input.inputId, input.delivery, input.message)
}
await session.cancelInput({ inputId: 'req-46' })
await session.changeInputDelivery({ inputId: 'req-47', delivery: 'steer' })
await migrationA client reads the waiting inputs from the snapshot, and sends the same two inputs:
const { waitingInputs = [] } = await client.snapshot()
for (const input of waitingInputs) {
console.log(input.inputId, input.delivery)
}
await client.cancelInput({ inputId: 'req-46' })
await client.changeInputDelivery({ inputId: 'req-47', delivery: 'steer' })Over POST control, they are { op: 'cancelInput', inputId } and { op: 'setDelivery', inputId, delivery }. In these two inputs, inputId is the id of the input that waits. A move sends a harness.input.delivery event with inputId and delivery, so every client can update its list.
A long command or a subagent blocks the turn, and the user wants to go on. session.moveToolCallToBackground() moves the running tool call to the background:
const build = session.send('Run the full test suite.', { inputId: 'req-48' })
// Later, while the `bash` call of that turn runs:
const moved = await session.moveToolCallToBackground()
console.log(moved.status) // 'accepted', or 'rejected' with 'not_running'
await buildThe session stays open while a job of this host runs, also when no handle holds it. A sweep with close: 'whenIdle' waits for these jobs too. The wake turn of a bash job runs as the user whose run started the job.
A client sends { op: 'background' } over POST control. It runs no new code, so it needs no expose entry:
await fetch('/api/harness/control', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
threadId: 'user-1-thread',
input: { op: 'background' },
}),
})Your own tool can support it too. Pass its work to detach from the tool context, and race the two promises:
const longReport = toolDefinition({
name: 'longReport',
description: 'Build the monthly report',
inputSchema: z.object({}),
}).server((_input, toolContext) => {
const work = new Promise<string>((resolve) => {
setTimeout(() => resolve('The report is ready.'), 60_000)
})
const detach = toolContext?.detach
return detach ? Promise.race([work, detach(work)]) : work
})Your tools often need to know what the user looks at: the open record, the page, or the locale. Send it as the context of the input. The session stores it with the input, so a turn that runs again after a restart gets the same value.
On the server, a tool reads it from its tool context:
import { toolDefinition } from '@tanstack/ai'
const openRecord = toolDefinition({
name: 'openRecord',
description: 'Read the id of the record that the user has open',
inputSchema: z.object({}),
}).server(async (_input, toolContext) => {
const context = toolContext?.context
const recordId =
typeof context === 'object' && context !== null && 'recordId' in context
? String(context.recordId)
: 'none'
return { recordId }
})
const records = defineHarness({
name: 'acme/records',
adapter: openaiText('gpt-6.1-sol'),
tools: [openRecord],
})
const recordSession = await host.open(records, { threadId: 'user-1-records' })
await recordSession.send('Summarize the record I have open.', {
context: { recordId: 'inv-42' },
})send takes context in every mode. A client sends it in a control input:
await fetch('/api/harness/control', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
threadId: 'user-1-thread',
input: {
op: 'prompt',
message: 'Summarize the record I have open.',
context: { recordId: 'inv-42' },
},
}),
})On POST run, the AG-UI forwardedProps of the request are the context, for example useChat({ forwardedProps }). See Connect clients to a harness.
Give the host acceptInput. It gets each input after the access check, and before the log keeps the input. Return the input, a changed input, or { refuse: reason }:
// Your own data: the plan of each user.
const plans = new Map([['ada', 'pro']])
const checkedHost = createHarnessHost({
persistence: memoryPersistence(),
acceptInput: (input, { user }) => {
if (input.op !== 'prompt') return input
if (typeof input.message === 'string' && input.message.length > 20_000) {
return { refuse: 'message_too_long' }
}
// The server sets the plan. A plan that the client sent is dropped.
return { ...input, context: { plan: plans.get(user?.id ?? '') ?? 'free' } }
},
})What you return decides what happens:
acceptInput also gets threadId. It runs for the calls of a session from host.open, for host.submit, and so for every POST control and POST run request. Inputs that the harness makes itself, such as a scheduled input when it runs, skip it.
On the client, a refused control input answers with the rejected receipt:
const response = await fetch('/api/harness/control', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
threadId: 'user-1-thread',
input: { op: 'prompt', message: 'Summarize the report.' },
}),
})
const sent = await response.json()
if (sent.status === 'rejected') console.warn(sent.reason) // 'message_too_long'A refused POST run answers 409 with { error: reason }, so useChat gets an error.