# 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](./durable-sessions), in one batch after the events that wait:

```ts group=harness-session-log
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](./durable-sessions#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](./shared-logs).
- In a tool, use `append` from [`durableTool`](./durable-tools), 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](./auth#keep-tokens-out-of-the-log-and-the-model).

### Keep records after a truncate

With [`keepLogRecords: 'since-snapshot'`](./lifecycle#drop-the-records-before-a-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)`:

```ts group=harness-session-log
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()`](./snapshots#go-back-to-an-earlier-message) 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:

```ts group=harness-session-log
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](./compaction).
- 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`.

1. On the server, list the record types in `expose.records`, and set `durability.addRecordsToModelContext` on the same harness:

```ts group=harness-session-log
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 },
})
```

2. On the client, add the record with `client.append`. Then start a turn with `client.continue`, so the model answers the signal:

```ts group=harness-session-log-client
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](./mcp-server#3-use-the-tools).

## 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`:

```ts group=harness-session-log
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](./handoffs).

- `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`.

```ts group=harness-session-log
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:

```ts group=harness-session-log
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:

```ts group=harness-session-log
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](./subagents#the-run-tree).
- Each `harness.usage` record has the `runId` of the run that made the model call. See [Usage of one run](./usage#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`](./subagents#share-state-between-agents).

## 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:

```ts group=harness-session-log
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.

```ts group=harness-session-log
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.
