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
- Providers
- 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
Reaping & Retention (Advanced)
The reaper stops a detached run that the user never resumes.
- The sandbox bills forever. Detach exists so the sandbox is not destroyed, so something else must destroy it.
- Clients wait forever. A detached run's log stays open on purpose, so a client that joins waits for an event that never comes.
- Finished work is lost. withPersistence saves the transcript in onFinish, on the host that drives the run. A run that finishes while detached has no such host, so its transcript is never saved.
reapDetachedRuns ends these runs. It is a function, not a scheduler. Your app must call it on a schedule. Nothing does it for you, and nothing warns you when you forget. A timer inside a library does not work on serverless platforms, so the library ships none.
Set up the sweep#
This setup uses the run-setup.ts module from Takeover & Detached Runs.
- Write the sweep once.
// sweep.ts
import {
probeRunExit,
reapDetachedRuns,
sandboxReclaimer,
} from '@tanstack/ai-sandbox'
import type { RunRecord } from '@tanstack/ai'
import type { ReapResult, RunExitProbe } from '@tanstack/ai-sandbox'
import {
durabilityFor,
driveRun,
instances,
locks,
runs,
sandbox,
} from './run-setup'
// Did the agent exit? Read the journal tail in the run's sandbox.
// Anything this cannot answer must be 'unknown', never 'finished'.
async function hasFinished(record: RunRecord): Promise<RunExitProbe> {
if (record.sandboxKey === undefined) return { state: 'unknown' }
try {
const instance = await instances.get(record.sandboxKey)
if (instance === null) return { state: 'unknown' }
const handle = await sandbox.provider.resume({
id: instance.providerSandboxId,
})
if (handle === null) return { state: 'unknown' }
return await probeRunExit({ handle, runId: record.runId })
} catch (error) {
return { state: 'unknown', error }
}
}
export function sweepDetachedRuns(): Promise<ReapResult> {
return reapDetachedRuns({
runs,
locks,
// Must resolve the same log the chat routes wrote.
durability: durabilityFor,
hasFinished,
// The same drive the takeover route uses.
drive: driveRun,
now: Date.now(),
// The only place this time limit is set. In milliseconds.
detachedRunTtlMs: 30 * 60 * 1000,
maxRuns: 25,
reclaim: sandboxReclaimer({ provider: sandbox.provider, instances }),
})
}- Call it on a schedule. Pick the one that fits your platform.
Plain Node. Keep one sweep in flight at a time. Two at once race for the same runs, and every second one reports not-claimed.
import { sweepDetachedRuns } from './sweep'
let inFlight = false
async function tick(): Promise<void> {
if (inFlight) return
inFlight = true
try {
const result = await sweepDetachedRuns()
console.log('reap', result.considered, result.outcomes)
for (const run of result.runs) {
if (
run.outcome === 'failed' ||
run.outcome === 'unknown' ||
run.outcome === 'reclaim-failed'
) {
console.warn('reap needs attention', run.runId, run.outcome, run.error)
}
}
} finally {
inFlight = false
}
}
setInterval(() => void tick(), 60_000)Vercel Cron. The route drives runs and destroys sandboxes, so guard it with CRON_SECRET.
// app/api/cron/reap/route.ts
import { sweepDetachedRuns } from '../../../../sweep'
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET
if (
secret === undefined ||
request.headers.get('authorization') !== `Bearer ${secret}`
) {
return new Response('Unauthorized', { status: 401 })
}
const result = await sweepDetachedRuns()
return Response.json({
considered: result.considered,
probed: result.probed,
outcomes: result.outcomes,
})
}{
"crons": [{ "path": "/api/cron/reap", "schedule": "*/5 * * * *" }]
}Cloudflare Durable Object alarm(). A Durable Object runs one alarm at a time, so you get the "one sweep in flight" guard for free.
import { sweepDetachedRuns } from './sweep'
// The part of DurableObjectState this needs. In a real Worker, pass
// `state.storage` from your Workers types.
interface AlarmStorage {
setAlarm: (scheduledTime: number) => Promise<void>
}
export class RunReaper {
constructor(private readonly storage: AlarmStorage) {}
async alarm(): Promise<void> {
try {
const result = await sweepDetachedRuns()
console.log('reap', result.considered, result.outcomes)
} finally {
// Re-arm in `finally`. An alarm that throws without a new alarm stops
// reaping forever.
await this.storage.setAlarm(Date.now() + 60_000)
}
}
}- Leave a run detached and wait one sweep. Its sandbox is destroyed, and if the agent had finished, its transcript is in the message store.
Warning
Watch the reclaim-failed count. It means the transcript is saved but the sandbox destroy failed, so the sandbox can still bill. No later sweep retries it. That log line is the only notice you get. See Reaping Internals.
How it works#
Each sweep:
- Lists every detached run once, with RunStore.listReclaimable.
- Takes up to maxRuns of them (25 by default) and handles them one at a time.
- Never rejects. Every failure is logged and counted in the returned ReapResult.
For each run:
| The run is | The sweep | Outcome |
|---|---|---|
| Past detachedRunTtlMs | Cancels it, drives it to a final status, destroys the sandbox. | expired |
| Finished (the journal has the exit line) | Drives it to a final status so onFinish saves the transcript, then destroys the sandbox. | finalized |
| Still working | Touches nothing. | producing |
| Not readable (no sandbox, a failed probe) | Touches nothing. | unknown |
| Claimed by another host | Touches nothing. A viewer probably came back. | not-claimed |
The sweep learns that a run finished from the journal in the sandbox, never by driving the run to see. It also never clears detachedSince, so the time limit keeps counting. The full outcome list, and why it works this way, is in Reaping Internals.
The reaper does not tidy journals inside a sandbox that stays alive. If you use keepAlive sandboxes, also schedule pruneJournals.
Size the time limit and the interval#
- Set detachedRunTtlMs from your agent's p99 task time, not from user patience. If an agent can run 20 minutes, 30 minutes is tight and an hour is safe. Too short cancels real work. Too long lets an abandoned run bill for that long.
- Sweep well under the time limit. The interval is how late an expired run can be, and how long a finished run waits for its transcript. Every minute against a 30-minute limit is cheap: one store query per minute. Every 30 minutes against a 30-minute limit can be an hour late.
- Do not raise maxRuns for a backlog. The cap keeps one call inside a Worker CPU budget or a Lambda timeout. Sweep more often instead.
Go deeper#
For every outcome, the journal probe, pruneJournals, sandbox reclaim, data retention, and the instance-store limit, see Reaping Internals.