A crash can stop a tool between two side effects: the card is charged, but the invoice is not sent. When the harness runs the tool again, the card is charged a second time. Put each side effect in a durable step. After a crash, the finished steps return their stored values, and only the rest run.
Make the tool with durableTool. Its execute gets step next to the normal tool context:
import { toolDefinition } from '@tanstack/ai'
import { durableTool } from '@tanstack/ai-harness'
import { z } from 'zod'
declare const billing: {
charge: (orderId: string) => Promise<{ id: string }>
sendInvoice: (chargeId: string) => Promise<void>
}
export const checkout = durableTool(
toolDefinition({
name: 'checkout',
description: 'Charge an order and send the invoice',
inputSchema: z.object({ orderId: z.string() }),
}),
async ({ orderId }, { step }) => {
const payment = await step.do(`charge:${orderId}`, () =>
billing.charge(orderId),
)
await step.do(`invoice:${payment.id}`, () =>
billing.sendInvoice(payment.id),
)
return { chargeId: payment.id }
},
)Add it to tools in defineHarness, as any other tool. Each finished step is in the session log before step.do resolves.
A step value must not hold a secret of the session, for example a key that a tool read. The log must not keep it, so step.do throws: on the first run, and on a run again after a crash. Read the secret outside step.do, and return an id, not a token. See Keep tokens out of the log and the model.
durableTool sets replay: 'safe', so the harness runs the tool again after a crash.
Some tools must not run again, for example a tool that sends an email. Pass { replay: 'never' } as the third argument:
declare const mailer: {
send: (to: string, body: string) => Promise<{ id: string }>
}
export const notify = durableTool(
toolDefinition({
name: 'notify',
description: 'Email a customer',
inputSchema: z.object({ to: z.string(), body: z.string() }),
}),
async ({ to, body }, { step }) => {
const mail = await step.do(`send:${to}`, () => mailer.send(to, body))
return { mailId: mail.id }
},
{ replay: 'never' },
)After a crash that cuts the call:
The replay option of durableTool decides this. A replay on the tool definition is not read.
append adds your own records to the session log. They land with the tool result, in the same append as its harness.tool.result record. If a crash cuts the tool call before its result, the records never happened.
export const remember = durableTool(
toolDefinition({
name: 'remember',
description: 'Remember a fact about the user',
inputSchema: z.object({ fact: z.string() }),
}),
async ({ fact }, { append }) => {
append([{ type: 'app.memory', fact }])
return 'Remembered.'
},
)A record type that starts with harness. is reserved and throws. Build on the session log shows how to read your records back.
After a crash, the harness looks at each call of the batch:
The kept result is the one that the model saw before the crash. The log keeps it after every onAfterToolCall hook, without the secrets of the session. If a hook replaced the result, the replacement wins, also on an error.
The same tool works in plain chat(). There, step.do runs fn each time, and step.sleep waits its full time. step.ask and append throw, because there is no log to hold the question or the records.