- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Build on the session log
Your framework keeps its own state next to the conversation: signals for the model, state writes, a compaction summary. Kept in a second store, it can drift from the transcript after a crash. Put these records in the harness session log. They share its atomic appends, its order, and its recovery.
Append your own records#
session.append adds records to the session log, in one batch after the events that wait:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const persistence = memoryPersistence()
const { log } = persistence.stores
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({ persistence })
const session = await host.open(assistant, { threadId: 'thread-1' })
await session.appendRecords({ records: [{ type: 'app.signal', text: 'The build failed.' }] })- A record is JSON with a type. Types that start with harness. belong to the harness, so appendRecords refuses them.
- appendRecords resolves with a receipt when the records are in the log. When another host owns the thread, the receipt is queued, and the owner writes the records. See One host drives a thread.
- Pass inputId to make the append safe to send again: session.appendRecords({ records, inputId: 'signal-7' }). A second append with the same id writes nothing. Without inputId, the append gets a new id. The receipt has the id, as the log keeps it. user sets who appends. The default is the user who opened the session.
- A record can belong to a child thread of the same log. See One log for a session tree.
- In a tool, use append from durableTool, so the records land with the tool batch.
- A key or a token of the session in a record goes into the log as [redacted]. See Keep tokens out of the log and the model.
Keep records after a truncate#
With keepLogRecords: 'since-snapshot', the store drops the records before each snapshot. A plugin can make the snapshot keep the last records of its own types with keepRecords, and read them back with ctx.session.keptRecords(type):
import { defineCommand, definePlugin } from '@tanstack/ai-harness'
export const steps = definePlugin({
name: 'acme/steps',
keepRecords: { 'acme/steps:step': 100 },
setup: (ctx) => ({
commands: {
steps: defineCommand({
description: 'Count the kept steps',
run: async () => (await ctx.session.keptRecords('acme/steps:step')).length,
}),
},
}),
})- The snapshot keeps the last 100 acme/steps:step records of each thread.
- keptRecords gives the kept records of that type of this thread, oldest first. It works before and after a truncate.
- snapshots() keeps its last 500 file steps this way, so session.revert() works after a truncate.
- The plugins of the harness of the root thread decide, because that harness folds the log. A type in two plugins keeps the larger count.
Fold records into the model context#
By default, the model does not see your records. Set durability.addRecordsToModelContext on the harness to fold a record into the messages. Its record function gets one object, { messages, record }, and returns the new list, or undefined for no change:
import type { ProjectOptions } from '@tanstack/ai-harness'
const project: ProjectOptions = {
version: 'v1',
record: ({ messages, record }) => {
if (record.type === 'app.signal' && typeof record.text === 'string') {
return [...messages, { role: 'user', content: record.text }]
}
return undefined
},
}
export const projected = defineHarness({
name: 'acme/projected',
adapter: openaiText('gpt-6.1-sol'),
durability: { addRecordsToModelContext: project },
})- record must be pure: the same log always folds to the same context, live and after a restart.
- The model sees the change at its next model call. A message that arrives during a tool call comes after the tool result.
- Readers see a message that record adds at the end of the list, for example the signal above. A change to earlier messages, for example a compaction summary, goes only to the model. session.transcript() keeps the full history.
- Change version when you change record. A log snapshot with another version is not used, so the host folds the log again from the first record.
- For compaction, use projectCompaction from @tanstack/ai-compaction. See Compact a harness session.
- A chat middleware in a harness turn can append records itself with getLogRecords(ctx).append([...]) from @tanstack/ai. The records land in the log at once. A plugin's middleware reads the writer with getLogRecords(ctx, { optional: true }). A plugin cannot list LogRecordsCapability in requires.
Let a client add records#
A webhook or a browser knows something that the model needs: the build failed, or a payment arrived. Let it add a record to the thread, and the next turn reads the record through durability.addRecordsToModelContext.
- On the server, list the record types in expose.records, and set durability.addRecordsToModelContext on the same harness:
import { createHarnessHandler } from '@tanstack/ai-harness/server'
export const watcher = defineHarness({
name: 'acme/build-watcher',
adapter: openaiText('gpt-6.1-sol'),
expose: { records: ['app.signal'] },
durability: { addRecordsToModelContext: project },
})
const watcherHost = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: ({ request }) =>
request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}`
? { id: 'ci-bot' }
: null,
// The CI bot is the only user, so it can use every thread.
rules: { isAllowed: ({ user }) => user.id === 'ci-bot' },
},
})
// Every instance signs media URLs with the same secret.
const mediaSecret = process.env.HARNESS_MEDIA_SECRET
if (!mediaSecret) throw new Error('Set HARNESS_MEDIA_SECRET.')
export const handler = createHarnessHandler({
host: watcherHost,
harness: watcher,
media: { secret: mediaSecret },
})- On the client, add the record with client.append. Then start a turn with client.continue, so the model answers the signal:
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { watcher } from './harness'
const client = createHarnessClient<typeof watcher>({
url: '/api/harness',
threadId: 'build-thread',
headers: { authorization: 'Bearer my-token' },
})
await client.appendRecords({ records: [{ type: 'app.signal', text: 'The build failed.' }] })
await client.continueWithoutMessage()- A record of another type is refused with not_exposed. So is a record with a thread field: a client writes only to its own thread.
- Types that start with harness. belong to the harness. defineHarness throws if expose.records lists one.
- An MCP client adds records with the append tool. See Use a harness from any MCP client.
Read the log#
The store is yours, so read your records back from it, for example to rebuild your state when a session opens. The store keeps each log under an id with its tenant, so build the id with logIdOf:
import { logIdOf } from '@tanstack/ai-harness'
// The default tenant. For a tenant, pass `tenantId` too: 'acme:thread-1'.
const entries = await log.read({ logId: logIdOf({ logId: 'thread-1' }) })
const signals = entries.filter((entry) => entry.record.type === 'app.signal')
console.log(signals.map((entry) => entry.seq))Each harness.input record has the user of its sender: its id, name, and tenantId. Every kind of input has it when the sender is known, also a cancel, an answer, a delivery change, and a change of settings.
Two harness records tell who answers a thread and where a conversation went. See Hand off a conversation.
- harness.handoff: another agent answers from now on. It has from and to (an agent name, null for the main model, or { human: true } for a person), the note, the runId, and by ('tool', 'code', 'router', or 'operator'). A handoff to a person also has the waitId of its human wait. The last one is the active agent of the thread.
- harness.transferred: host.transfer moved the conversation. The log of the target has from, and the log of the source has to. Each is { harness, threadId }. Both records have the same transferId, the user, and at.
const handoffs = entries.filter((entry) => entry.record.type === 'harness.handoff')
console.log(handoffs.at(-1)?.record.to)A reader outside a session that needs a MessageStore, for example reconstructChat, uses logMessageStore. It folds the log with the same addRecordsToModelContext, and it reads and writes the transcript that readers see, not the model context. Pass tenantId to read the threads of a tenant:
import { logMessageStore } from '@tanstack/ai-harness'
const messages = logMessageStore({ store: log, project })
console.log(await messages.loadThread('thread-1'))Find the runs in the log#
Your dashboard shows every agent run of a thread, also the runs that ended long ago. The log has one harness.run.node record for each run that the session admits:
const records = (await log.read({ logId: logIdOf({ logId: 'thread-1' }) })).map(
(entry) => entry.record,
)
const agentNodes = records.filter(
(record) => record.type === 'harness.run.node' && record.kind === 'agent',
)
console.log(agentNodes.map((record) => [record.runId, record.agent, record.parentRunId]))- A harness.run.node record has runId, kind (turn, agent, or command), and the user when it is known. An agent run also has agent, and parentRunId, key, limits, and budget when it has them.
- Read only these fields. The other fields of the record are bookkeeping of the session, and they can change.
- The tree also has tool runs. These come from harness.run records, not from harness.run.node records.
- The status, the result, and the error come from later records: harness.run, harness.run.update, and harness.agent.result. A session folds them into one tree. See The run tree.
- Each harness.usage record has the runId of the run that made the model call. See Usage of one run.
- A harness.state record has a key and one change to its value. It is the shared state of ctx.harness.state.
Encrypt the records in the store#
Wrap the LogStore to encrypt the records that hold what people wrote. The host reads and writes through the wrapper, so it only sees plain records. HarnessRecord is the type of every record that the harness writes, so a wrong record type is a type error:
import { createCipheriv, createDecipheriv, createHmac } from 'node:crypto'
import { defineLogStore } from '@tanstack/ai-persistence'
import type { LogRecord, LogStore } from '@tanstack/ai-persistence'
import type { HarnessRecord } from '@tanstack/ai-harness'
// The harness records that hold messages, prompts, and tool results.
const SEALED: ReadonlySet<string> = new Set([
'harness.transcript',
'harness.input',
'harness.event',
'harness.tool.result',
] satisfies Array<HarnessRecord['type']>)
interface Keys {
aes: Buffer // 32 bytes
iv: Buffer // a second secret
}
// AES-256-GCM. The IV comes from the text, so the same text gives the same output.
function seal(keys: Keys, text: string) {
const iv = createHmac('sha256', keys.iv).update(text).digest().subarray(0, 12)
const cipher = createCipheriv('aes-256-gcm', keys.aes, iv)
const data = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64')
}
function unseal(keys: Keys, sealed: string) {
const bytes = Buffer.from(sealed, 'base64')
const decipher = createDecipheriv('aes-256-gcm', keys.aes, bytes.subarray(0, 12))
decipher.setAuthTag(bytes.subarray(12, 28))
return Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8')
}
const isLogRecord = (value: unknown): value is LogRecord =>
typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string'
export function sealedLogStore(store: LogStore, keys: Keys): LogStore {
const sealRecord = (record: LogRecord): LogRecord =>
SEALED.has(record.type)
? { type: record.type, sealed: seal(keys, JSON.stringify(record)) }
: record
const openRecord = (record: LogRecord): LogRecord => {
if (!SEALED.has(record.type) || typeof record.sealed !== 'string') return record
const opened: unknown = JSON.parse(unseal(keys, record.sealed))
if (!isLogRecord(opened)) throw new Error('A sealed record did not open.')
return opened
}
return defineLogStore({
append: (batch) => store.append({ ...batch, records: batch.records.map(sealRecord) }),
read: async (query) =>
(await store.read(query)).map((entry) => ({ ...entry, record: openRecord(entry.record) })),
offerInput: (input) => store.offerInput({ ...input, record: sealRecord(input.record) }),
listOfferedInputs: async (query) =>
(await store.listOfferedInputs(query)).map((offer) => ({
...offer,
record: openRecord(offer.record),
})),
// The snapshot holds the transcript too.
putSnapshot: (snapshot) =>
store.putSnapshot({ ...snapshot, state: seal(keys, JSON.stringify(snapshot.state)) }),
getSnapshot: async (query) => {
const snapshot = await store.getSnapshot(query)
if (!snapshot || typeof snapshot.state !== 'string') return snapshot
const state: unknown = JSON.parse(unseal(keys, snapshot.state))
return { ...snapshot, state }
},
// These calls carry no records.
subscribe: (watch) => store.subscribe(watch),
acquireOwnership: (claim) => store.acquireOwnership(claim),
renewOwnership: (claim) => store.renewOwnership(claim),
releaseOwnership: (claim) => store.releaseOwnership(claim),
pinToWorkers: (pin) => store.pinToWorkers(pin),
markHostAlive: (beat) => store.markHostAlive(beat),
listLogsWithoutOwner: (query) => store.listLogsWithoutOwner(query),
truncate: (cut) => store.truncate(cut),
deleteLog: (target) => store.deleteLog(target),
})
}
const keys = {
aes: Buffer.from(process.env.LOG_AES_KEY ?? '', 'base64'),
iv: Buffer.from(process.env.LOG_IV_KEY ?? '', 'base64'),
}
const sealedHost = createHarnessHost({
persistence: { stores: { ...persistence.stores, log: sealedLogStore(log, keys) } },
})CAUTION: The wrapper must give back on read exactly what it got on write: the same fields, the same values, and the same key order. If it does not, the duplicate-input checks and the folds of the session break. The host compares the records that it wrote with the records that it reads back. The store compares a second offer of an input with the first one by its JSON.
- seal must give the same output for the same text. With a random IV, a second offer of the same input is a conflict, not a duplicate. The cost: two equal records have equal ciphertext.
- Keep type in the clear. The record types that you do not seal (thread settings, runs, usage) stay readable for your dashboards.
- To redact in place of encrypt, keep the real values in your own store and put them back on read. A wrapper that drops data on write breaks the log.
Tune how text streams into the log#
A host merges streamed text and tool call arguments for log.bufferStreamWriteMs milliseconds (default 100) before it appends them. A message start or end, a tool call start or end, and your own records append at once.
const smoothHost = createHarnessHost({
persistence: memoryPersistence(),
log: { bufferStreamWriteMs: 250 },
})A larger value writes less often, and clients get text in larger parts.
What you have now#
- Your records in the same durable log as the conversation.
- A model context that folds them in, the same live and after a restart.
- Records that a webhook or a browser adds, for the record types you expose.
- A transcript that any reader can fold from the log.
- Every run of the thread, and the shared state of its agents, in records that you can read.
- A log store that keeps the messages of your users encrypted.