TanStack
Harness

One log for a session tree

Your framework runs one agent with a main conversation and child tasks. If each conversation has its own log, two things go wrong. A record for the child and a record for the parent can land one without the other after a crash. And no one place holds the state of the whole agent. A harness keeps the whole tree in one log, so the threads share its order, its atomic appends, and one state fold.

One log per session tree

Open the root thread. Each child thread that the harness starts writes to the log of that root:

  • An agent run and its follow-ups (<threadId>:<name>:<input id>).
  • A subagent run (subagent:<runId>).
  • A session of another harness that runs as a child with harnessAgent (<root thread id>/agent/<run id>).
ts
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'

const persistence = memoryPersistence()
const assistant = defineHarness({
  name: 'acme/assistant',
  adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({ persistence })

const root = await host.open(assistant, { threadId: 'agent-1' })
  • The log has the id of the root thread, in its tenant: logIdOf({ tenantId, logId: 'agent-1' }).
  • Each thread folds only its own transcript, inputs, and events from the log. A child never sees the messages of the root.
  • A child thread does not open. Open its root, and read the child with session.agentTranscript({ subagentRunId }). host.open throws for the thread id of a harnessAgent child.
  • host.observe also reads a child thread by its own thread id. It finds the root through the session index, so it needs stores.sessions. memoryPersistence() has it.

Write to several threads in one append

session.append adds records to the root thread. A record with thread: '<other threadId>' belongs to that thread of the same log. The whole append is all or nothing:

ts
await root.appendRecords({
  records: [
    { type: 'app.child_created', thread: 'task-1', task: 'research' },
    { type: 'app.child_linked', child: 'task-1' },
  ],
})

Here the child record and the link in the root land together, or not at all.

  • thread must be a string. Any other value makes appendRecords throw.
  • A record without thread belongs to the root thread.

Keep state for the whole tree

Give the harness a durability.logState option. It folds every host record of the log, from the root and from each child thread, in log order. Then session.logState() reads the result:

ts
import type { ReduceOptions } from '@tanstack/ai-harness'

interface Children {
  tasks: Array<string>
}

const reduce: ReduceOptions<Children> = {
  initial: { tasks: [] },
  version: 'v1',
  record: ({ state, record }) =>
    record.type === 'app.child_created' && typeof record.thread === 'string'
      ? { tasks: [...state.tasks, record.thread] }
      : state,
}

const tracker = defineHarness({
  name: 'acme/tracker',
  adapter: openaiText('gpt-6.1-sol'),
  durability: { logState: reduce },
})
const tracked = await host.open(tracker, { threadId: 'agent-2' })
await tracked.appendRecords({
  records: [{ type: 'app.child_created', thread: 'task-2', task: 'research' }],
})

console.log((await tracked.logState())?.tasks)
  • record must be pure. It returns the next state, or the same state for no change.
  • The type of the state flows from initial and record. Here logState() resolves to Children | undefined. A harness without logState gets undefined.
  • The fold comes from the log, so a session on another machine gets the same state.
  • 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.
  • durability.addRecordsToModelContext also works with logState. It folds a host record into the messages of its own thread only.
  • Every harness that opens the same log must pass the same addRecordsToModelContext and logState objects. When a harness with other ones opens a log that is open, host.open throws, and so do the reads of host.observe.

A read route that opens no session gets the same state from a view:

ts
const view = await host.observe(tracker, { threadId: 'agent-2' })
console.log((await view.logState())?.tasks)

One writer per log

Only one host writes a log at a time. When another host takes the log, the sessions of that log on this host stay open and read on. Their next write takes the log back, when no other host owns it. See One host drives a thread.

One root thread has one open session per host. A second harness on the same thread throws.

To read a root thread outside a session, for example for reconstructChat, use logMessageStore:

ts
import { logMessageStore } from '@tanstack/ai-harness'

const messages = logMessageStore({ store: persistence.stores.log })
console.log(await messages.loadThread('agent-1'))

Each thread id that you pass is the root of a tree. To read a child thread, use host.observe.

What you have now

  • One durable log for a root thread and all of its child threads, each with its own transcript.
  • Appends that reach several threads and land all together or not at all.
  • One state for the whole tree, from durability.logState, that you read with session.logState() on any machine.