Your server opens a session for each request. Each open session holds its plugins, its MCP processes, and its recent events in memory. A server that never lets them go grows until it runs out of memory. The host closes the sessions that nobody uses, caps how many stay open, and bounds what each session keeps.
Each host.open gives you a handle: your own reference to the live session of the thread. When you are done with the handle, release it. With await using, the release runs at the end of the block:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({ persistence: memoryPersistence() })
export async function reply(threadId: string, message: string) {
await using session = await host.open(assistant, { threadId })
const turn = await session.send(message)
return turn.text
}The host closes a session when no handle holds it and it was idle for lifecycle.closeIdleSessionsAfterMs. Set the limits on the host:
const busyHost = createHarnessHost({
persistence: memoryPersistence(),
lifecycle: { closeIdleSessionsAfterMs: 30_000, maxOpenSessions: 200 },
})A plugin with lifetime: 'host' stays set up while another session uses it. See Share one setup between sessions.
host.close() closes every session of the host, for every handle. Then host.open throws:
await busyHost.close()A preview route or a status page only reads a thread. An open sets up every plugin, and can start MCP processes. host.observe reads the thread and opens no session.
On the server, read the transcript in your route:
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() })
export async function GET(request: Request) {
const threadId = new URL(request.url).searchParams.get('threadId')
if (!threadId) return new Response('Missing threadId', { status: 400 })
const view = await host.observe(assistant, { threadId })
return Response.json(await view.transcript())
}In the browser, fetch the route:
const response = await fetch('/api/preview?threadId=chat-1')
console.log(await response.json())On a host with access, pass the user of the request. The thread needs the read action, else observe throws AccessDeniedError. A thread with no index entry gets the same error, so a read creates no thread.
What the view reads for the chat:
What the view reads for the rest:
The view sets up no plugins, writes nothing, and never takes the log, so a read never fails because another host owns the thread. When the session is open on this host, the view reads through it, but it never holds the session open. The read paths of createHarnessHandler use the view, so a client that only reads starts no plugins. GET describe opens the session, because the plugins give the commands and the settings.
A thread can run for weeks. Each turn and each agent run that settles leaves its receipt, its result, and its events in memory. lifecycle.retention keeps a window of each and drops the oldest:
const leanHost = createHarnessHost({
persistence: memoryPersistence(),
lifecycle: {
retention: { keepSettledOperations: 200, keepSettledAgentRuns: 100, maxEventBytesInMemory: 2_000_000 },
},
})| Limit | Default | Past the limit |
|---|---|---|
| keepSettledOperations | 500 | session.operation(id) is undefined for the oldest settled turns, commands, and resets. |
| keepSettledAgentRuns | 500 | session.agentRun(id) and session.agentRuns() leave out the oldest settled agent runs. |
| maxEventBytesInMemory | 8 000 000 | session.events() keeps the newest events, by the JSON length of each event. A reader with an older cursor reads the older events from the log. |
The log keeps what memory drops:
The log store keeps every record by default. To keep it small, let the owner drop the records that a snapshot already holds:
const trimmedHost = createHarnessHost({
persistence: memoryPersistence(),
lifecycle: { retention: { keepLogRecords: 'since-snapshot' } },
})CAUTION: With 'since-snapshot', do not change durability.logState or durability.addRecordsToModelContext often. When the host cannot use the snapshot (its logState version or its project changed, or the snapshot has a newer version), a thread with dropped records cannot open. The open rejects with LogTruncatedError. Keep 'all' if you change reduce often.
A user deletes a chat, and its data must go with it:
await leanHost.sessions.delete({ threadId: 'chat-1' })First, delete closes the sessions of the thread and of its child threads, for every handle. The host finds the children through the parentThreadId of their index entries. Then it deletes this data:
The first-party plugins also end the thread:
A host knows a harness when it opened a session of it. After a deploy or a restart, a new host has opened nothing. Give it the harnesses that it can delete threads of:
const deleteHost = createHarnessHost({
persistence: memoryPersistence(),
harnesses: [assistant],
})A host that does not know the harness of a thread still deletes the data. It warns, and the plugins do not end the thread: an owned sandbox stays.
To remove only the messages of one person and keep the thread, see Erase the messages of one person.