Your app needs a sidebar of past chats. It shows the newest chat first, a title for each chat, and a mark on a chat that runs or waits for an answer. The host keeps a session index for this, with one entry per thread. It writes the entry when a thread opens and after each turn.
Give the host a sessions store. memoryPersistence() has one:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
// The app data of each session. See "Keep app data on a session" below.
export const pinnedFilesSchema = z.object({ pinnedFiles: z.array(z.string()) })
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
metadataSchema: pinnedFilesSchema,
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(assistant, {
threadId: 'chat-1',
user: { id: 'ada' },
})
await session.send('Plan a trip to Lisbon.')
console.log((await host.sessions.get({ threadId: 'chat-1' }))?.members) // [{ userId: 'ada', role: 'owner' }]host.open gives you a handle. When you are done with it, release it with await session.close(). Or open it with await using, which releases it at the end of the block. Then the session can close when it goes idle. See Lifecycle and retention.
What an entry holds:
The host takes sessions next to log:
import { memoryLogStore } from '@tanstack/ai-persistence'
const { sessions } = memoryPersistence().stores
const durableHost = createHarnessHost({
persistence: { stores: { log: memoryLogStore(), sessions } },
})Each method of host.sessions takes one object with a threadId or filters, and a user. The tenant of user picks the entries. On a host with access, the user is required, and the host checks each call. See Access and tenants.
const page = await host.sessions.list({ user: { id: 'ada' }, limit: 20 })
for (const entry of page.entries) {
console.log(entry.threadId, entry.title ?? 'New chat')
}
if (page.truncated) {
const next = await host.sessions.list({
user: { id: 'ada' },
limit: 20,
cursor: page.cursor,
})
console.log(next.entries.length)
}To find sessions, add filters. All filters must match, and limit and cursor page the filtered list:
const found = await host.sessions.list({
user: { id: 'ada' },
search: 'trip',
harness: 'acme/assistant',
metadata: { project: 'p-1' },
})A fork copies a chat into a new chat, up to a message. Use it to try another answer, or to edit a message and send it again:
const [first] = await session.transcript()
if (!first?.id) throw new Error('The chat has no messages.')
const forked = await host.sessions.fork({
harness: assistant,
threadId: 'chat-1',
at: { before: first.id },
})
const fork = await host.open(assistant, { threadId: forked.threadId })
await fork.send('Plan a trip to Porto.')What a fork copies, and what it does not copy: Fork and reset a thread.
A fork keeps the old chat. To go back in the same chat, revert it to a message:
const [question] = await session.transcript()
if (!question?.id) throw new Error('The chat has no messages.')
await session.revert({ messageId: question.id })
// The transcript now ends at `question`. To bring the later messages back:
await session.unrevert()await host.sessions.rename({ threadId: forked.threadId, title: 'Porto trip' })
await host.sessions.delete({ threadId: 'chat-1' })For the full list of what delete removes, and what stays, see Delete a thread for good.
Archive a chat to take it out of the sidebar and keep all of its data:
await host.sessions.archive({ threadId: forked.threadId })
const active = await host.sessions.list() // no archived chats
const all = await host.sessions.list({ withArchived: true })
console.log(active.entries.length, all.entries.length)
await host.sessions.unarchive({ threadId: forked.threadId })From the browser, client.sessions has the same calls:
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { assistant } from './harness'
const client = createHarnessClient<typeof assistant>({
url: '/api/harness',
threadId: 'chat-1',
})
await client.sessions.archive({ threadId: 'chat-1' })
const page = await client.sessions.list({ withArchived: true })
console.log(page.entries.filter((entry) => entry.archived))
await client.sessions.unarchive({ threadId: 'chat-1' })Your composer lets a user pin files to a chat. The pins must stay with the chat, show again after a reload, and work on each of your servers. Keep them in the metadata of the session entry.
The assistant at the top of this page has a metadataSchema. It types metadata, and each write checks it.
Write the pins from your server code. No session has to be open:
await host.sessions.update({
harness: assistant,
threadId: forked.threadId,
update: { metadata: { pinnedFiles: ['src/auth.ts'] } },
})
// Add one file, and keep the files that are there.
await host.sessions.update({
harness: assistant,
threadId: forked.threadId,
update: (entry) => ({
metadata: {
pinnedFiles: [...(entry.metadata?.pinnedFiles ?? []), 'src/login.tsx'],
},
}),
})A turn on one server writes the usage of the thread. At the same time, the app on another server can write the pins. The store keeps both writes with getVersioned and upsertIf: when another server wrote first, the host runs your change function again on the new entry. For the rules of your own store, read Keep the writes of several hosts.
Give the plugin the same schema. Then ctx.session.indexEntry() and ctx.session.updateIndexEntry() are typed, and the schema checks each write of the plugin:
import { defineCommand, definePlugin } from '@tanstack/ai-harness'
export const pins = definePlugin({
name: 'acme/pins',
metadataSchema: pinnedFilesSchema,
setup: (ctx) => ({
commands: {
unpin: defineCommand({
description: 'Unpin a file from this chat',
input: z.object({ file: z.string() }),
run: ({ file }) =>
ctx.session.updateIndexEntry((entry) => ({
metadata: {
pinnedFiles: (entry.metadata?.pinnedFiles ?? []).filter(
(pinned) => pinned !== file,
),
},
})),
}),
},
}),
})Add pins to the plugins of the harness. If the harness metadata does not fit the schema of a plugin, TypeScript shows an error. The turn hooks of the harness also get a typed session.indexEntry().
host.events() yields the changes of status and of the index. First it yields the current status of each open session:
const stop = new AbortController()
async function watch() {
for await (const event of host.events({ signal: stop.signal })) {
if (event.type === 'status') console.log(event.threadId, event.status)
if (event.type === 'session') console.log('changed', event.entry.title)
if (event.type === 'session-deleted') console.log('removed', event.threadId)
}
}
void watch()Each host reports only the sessions that are open in it. For those sessions, the status also counts the turns and the questions that another host writes to the same log. A session with no status event is not open, so show it as idle.
A reader that does not keep up stops. When more than 1 000 changes wait for it, its loop ends. The first statuses do not count. Call host.events() again: the current statuses come first again.
host.close() does not end the readers of events(). Abort the signal of each reader before you close the host. If you do not abort it, the loop waits forever:
stop.abort()
await host.close()Want to do something each time a session sends a certain event? Hand the session a listener. It calls your function for you, so you do not need a loop:
const stopListening = session.onEvent('RUN_ERROR', ({ operationId, event }) => {
console.error(`Operation ${operationId} failed: ${event.message}`)
})
// Later, when you do not need it any more:
stopListening()