Persistence (Advanced)
- Build your first harness
- Host options
- Send and show media
- Connect clients
- Write a client in any language
- Access and tenants
- Run in the terminal
- Deploy a harness
- Send inputs safely
- Run a tool without a model
- Durable sessions
- Run background work on workers
- List, rename, and fork sessions
- Lifecycle and retention
- Run side effects once
- Wait for a person
- Build on the session log
- Control how a turn ends
- Store settings per thread
- Compact a long session
- Fork and reset a thread
- One log for a session tree
- Write a plugin
- Track usage and cost
- Ask before risky tool calls
- Give your agent coding tools
- Sandbox and extend the coding tools
- Run the coding tools for many threads
- Undo a turn
- Build a coding agent
- Delegate to coding agents
- Switch between agent profiles
- Auth and connectors
- Share a thread between people
- Connect model providers
- Build your own UI
- Code mode in a harness
- Work until a goal is met
- Run agents from a harness
- Hand off a conversation
- Add skills
- Use MCP servers
- Give your agent a computer
- Use from any MCP client
- Use a harness from an editor (ACP)
- Overview
- Quick Start
- ProvidersUpdated
- Harnesses
- Harness Auth
- Workspace
- Tools
- Policy
- Lifecycle & Snapshots
- Portable Snapshots
- Keep Files After Reload
- Save a Named Version
- Branch From a Version
- Send a Frozen File
- Let the Agent Save and Fork
- Pick Which Files to Keep
- What a Snapshot Stores
- Instance Durability
- Durable Runs
- Events
Sandboxes (Advanced)
- Runtime Context
- Bring Your Own Key
- Keep Keys on the Server
- Debug Logging
- Multimodal Content
- Files API
- Per-Model Type Safety
- Runtime Adapter Switching
- Tree-Shaking
- Extend Adapter
- Extend Adapter: Advanced
- Typed Pre-Configured Options
- Test with a Fake Model
- Prompt Caching
- Mid-Conversation Changes
- Approval Flow Processing
Run background work on workers
Some work has no user who waits for it: a report that a cron job asks for, a reminder in one day, a tool that waits for a payment, or a turn that a deploy cut. Nobody opens these threads, so nothing runs them. A worker finds each thread in the session log that has work and no live host, takes it, runs the work, and gives it back.
Every host on this page shares one log store. In production, that is your LogStore. Here, memoryLogState() gives two hosts one memory log, the same as two servers on one database.
1. Start a worker#
Make a host for the worker, and start the worker on it:
import {
createHarnessHost,
createHarnessWorker,
defineHarness,
} from '@tanstack/ai-harness'
import { memoryLogState, memoryLogStore } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
// One log store for every host. Swap in your own store.
const state = memoryLogState()
const hostOn = () =>
createHarnessHost({ persistence: { stores: { log: memoryLogStore({ state }) } } })
const workerHost = hostOn()
const worker = createHarnessWorker({ host: workerHost, harnesses: [assistant] })- harnesses: the harnesses whose threads the worker runs, matched by name.
- concurrency: at most how many threads the worker runs at the same time. The default is 8.
- pollMs: how long the worker waits after a look that found no work. The wait doubles after each empty look, up to 30 seconds. The default is 1 000.
- signal: an AbortSignal that stops the worker, the same as stop().
Run as many workers as you want, on as many machines. Each thread goes to one of them. Each thread opens in its own tenant, with no user.
If a thread fails to open, the worker logs the error and skips that thread for a time. The time doubles after each failure in a row, up to 5 minutes. The other threads still run.
2. Submit an input from any server#
A cron route or a webhook wants the agent to do a job, but it must not wait for the turn. host.submit stores the input in the log and runs nothing:
const apiHost = hostOn()
const receipt = await apiHost.submit({
harness: assistant,
threadId: 'weekly-report',
input: { op: 'prompt', message: 'Write the weekly report.' },
inputId: 'weekly-report-2026-42',
})
console.log(receipt.status) // 'queued'- The input waits until a host owns the thread. That is the host that runs the thread now, or a worker.
- Pass inputId so that a retry is safe. The same id with the same input is a duplicate. The same id with another input gets rejected with conflict.
- input is any input that runs on the owner: a prompt, a steer, a follow-up, an answer, a cancel, a command, and more.
- On a host with access, pass user. The input needs the same action as on a handle, else submit throws AccessDeniedError and stores nothing.
- An answer or a cancel is checked with the run that it targets, from the log. A cancel must name its operationId. A run that the log does not have gets rejected with unknown_run. To drop an input that waits, use cancelInput.
3. Run an input later#
session.schedule runs an input at a time that you pick. The log keeps the schedule, so it runs once, also after a restart or on another machine:
const session = await apiHost.open(assistant, { threadId: 'daily-summary' })
const { scheduleId } = await session.schedule({
at: Date.now() + 60 * 60 * 1000,
input: { op: 'prompt', message: 'Write the daily summary.' },
})
await session.close()- While a host has the thread open, that host runs the input at at.
- When no host has the thread open, the log store wakes a worker at at.
- A time that passed runs at once.
- Pass scheduleId to pick the id. A second call with the id of a schedule that waits writes nothing.
- user is who sends the input when it runs. The default is the user of the open.
To stop a schedule that did not run yet:
const later = await apiHost.open(assistant, { threadId: 'daily-summary' })
await later.cancelSchedule({ scheduleId })
await later.close()A schedule that ran, or an unknown id, changes nothing. While another live host owns the thread, schedule and cancelSchedule throw.
4. Wait inside a tool#
A tool sends a reminder, waits one day, and then sends a follow-up. step.sleep in a durable tool waits, and the log keeps the time it wakes:
import { toolDefinition } from '@tanstack/ai'
import { durableTool } from '@tanstack/ai-harness'
import { z } from 'zod'
declare const mail: {
send: (message: { to: string; text: string }) => Promise<void>
}
export const remind = durableTool(
toolDefinition({
name: 'remind',
description: 'Remind a customer, and follow up one day later',
inputSchema: z.object({ to: z.string() }),
}),
async ({ to }, { step }) => {
await step.do('send-reminder', () => mail.send({ to, text: 'Your invoice is due.' }))
await step.sleep({ name: 'wait-a-day', ms: 24 * 60 * 60 * 1000 })
await step.do('send-follow-up', () => mail.send({ to, text: 'Your invoice is late.' }))
return 'Sent.'
},
)- A sleep of 30 seconds or less waits in the process. The host keeps the thread while it waits.
- A longer sleep on a thread that only a worker runs gives the thread back to the log store with a wake. The worker slot is free, and worker.stop() does not wait for the sleep. On a thread that your app has open, the sleep waits in the process.
- At the wake time, or when the host stops, a worker runs the tool call again. The finished steps return their stored values, and the sleep waits only for the time that is left.
- name is a step name. Use the same name on every run, and not the name of a do step of the same call.
- Agent code has the same step.sleep on ctx.harness.step, in a run started with runAgainAfterCrash: true. See Keep a background agent going after a crash.
5. Restart for a deploy#
Stop a server with recoverable: true. Its running work stops, and the host gives each busy thread back with a wake, so a worker takes it at once:
process.on('SIGTERM', async () => {
await apiHost.close({ recoverable: true })
process.exit(0)
})A worker host stops the same way. To let its threads end their work first, call worker.stop(). It stops the looks for work, and waits until the threads that the worker runs close:
process.on('SIGTERM', async () => {
await worker.stop()
await workerHost.close()
})To stop the work at once, close the host with recoverable: true instead. Another worker runs that work again.
What you have now#
- A pool of workers that runs every thread with work and no live host.
- Inputs that any server submits, and inputs that run at a time you pick.
- Tools that wait for hours, and continue on another machine after a stop.
- Deploys that hand the cut work to a worker at once.
A client reads these threads as any other thread. See Connect clients.