Skip to content
Harness

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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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.