Agent memory, scoped to where it matters.

Memory in the harness is not a global store. Every read and write is scoped — to the session, the run, the agent, the user, or the tenant. The facade handles namespacing. You choose how and where values are persisted.

Two stores, one session

Memory and history are different things

Every session has two stores. History is the conversation — the ordered list of messages that the model sees on the next turn. Memory is a key-value store your application and agents can read and write freely between runs.

History

The ordered message log for the current session. The harness feeds the configured historyWindow into each new model request. Persisted through the StateStore.

snippet.ts typescript
const messages = await session.history.list({ limit: 20 })
// returns: Message[] — role, content, timestamps
Memory

A key-value store for anything the agent or workflow should be able to recall: preferences, prior topics, user context, intermediate results. Scoped and structured. Not visible to the model unless you explicitly include it in a prompt.

snippet.ts typescript
await session.memory.write('last-topic', { topic: 'pricing' })
const prior = await session.memory.read<{ topic: string }>('last-topic')
// -> { topic: 'pricing' }
Five scopes

Put memory exactly where it belongs

The memory facade exposes five scopes. Each scope is namespaced so two agents, users, or tenants never share the same key space accidentally.

session lives for the session

One conversation thread. Write values here that any agent or workflow in this session should be able to read back.

snippet.ts typescript
await ctx.memory.session.write('locale', 'de-DE')
const locale = await ctx.memory.session.read<string>('locale')
run lives for one agent/workflow run

Scratch space for intermediate results within a single run. Use it to pass data between tool calls without polluting session memory.

snippet.ts typescript
await ctx.memory.run.write('retrieved-ids', ['doc-1', 'doc-7'])
const ids = await ctx.memory.run.read<string[]>('retrieved-ids')
agent per agent within the session

Isolated per agent id within the session. Present in agent and TypeScript tool contexts. Two agents in the same session never share agent-scoped keys.

snippet.ts typescript
// inside an agent handler or tool handler
await ctx.memory.agent?.write('style-preference', 'concise')
const pref = await ctx.memory.agent?.read<string>('style-preference')
user(id?) across sessions, per user

Persists across sessions for a given user. The facade picks up metadata.userId automatically, or you can pass the id explicitly.

snippet.ts typescript
// explicit id
await ctx.memory.user('u_123').write('timezone', 'Europe/Berlin')

// uses metadata.userId from the session
const tz = await ctx.memory.user().read<string>('timezone')
tenant(id?) across sessions, per tenant

Shared across all users in a tenant. Good for org-level settings, shared reference data, and tenant-specific configuration.

snippet.ts typescript
await ctx.memory.tenant('t_acme').write('approved-models', ['gpt-4o-mini'])
const approved = await ctx.memory.tenant().read<string[]>('approved-models')
scope(opts) explicit, for edge cases

Build an explicit MemoryScope when you need a combination not covered by the helpers — for example, a cross-agent, cross-session index scoped to a specific workflow.

snippet.ts typescript
const handle = ctx.memory.scope({
  kind: 'user',
  userId: 'u_123',
  tenantId: 't_acme',
})
await handle.write('onboarding-complete', true)
The facade API

Five operations, every scope

Every scope handle exposes the same five operations. The facade handles scoping, validation, telemetry, and content-capture policy. Your code just reads and writes values.

All five operations typescript
// read — returns undefined when absent
const value = await ctx.memory.session.read<MyType>('key')

// write — with optional options
await ctx.memory.session.write('key', { value: 42 }, {
  ttlMs: 3_600_000,      // expire after 1 hour (requires memory.ttl)
  tags: ['cache', 'v2'], // filterable tags
  metadata: { source: 'tool-result' },
})

// delete
await ctx.memory.session.delete('key')

// list — metadata only, no values
const entries = await ctx.memory.session.list({
  prefix: 'cache/',
  limit: 50,
})

// search — requires memory.search capability
const results = await ctx.memory.session.search({
  text: 'pricing policy',
  limit: 10,
  tags: ['docs'],
})

Write options

ttlMs Positive expiry duration in ms. Requires the adapter to declare memory.ttl.
tags String array for filtering list() and search() results.
metadata JSON metadata stored beside the value. Returned in list() entries.

List result shape

snippet.ts typescript
interface MemoryEntry {
  key: string
  createdAt?: string   // ISO timestamp
  updatedAt?: string
  expiresAt?: string   // set when ttlMs was used
  tags?: string[]
  metadata?: Record<string, JsonValue>
  // note: no value — use read() for values
}

Content capture. Memory content follows the harness contentCaptureMode. With the default NO_CONTENT, values are never emitted to telemetry. The adapter respects this setting for any custom spans or metrics it emits.

1
Default adapter

sandboxMemory() — zero config, zero setup

When you don't call .memory(...), the harness uses sandboxMemory() automatically. It stores everything as JSON files inside the session sandbox. No database, no network, no extra dependencies.

Default behaviour typescript
// default — no .memory() call needed
const harness = defineHarness({ name: 'my-service' })
  .models({ ... })
  .agents({ ... })
  .build()

// session memory lives at:
// /memory/session/<key>.json

// run memory lives at:
// /memory/runs/<runId>/<key>.json

What sandboxMemory can do

read, write, delete, list
session and run scopes
No TTL — values don't expire
No semantic search (memory.search not declared)
No persistence across process restarts
No user or tenant scopes

Good for development and stateless agents. sandboxMemory() is the right default for local development and agents that don't need cross-session recall. Move to a dedicated adapter when users or tenants need memory that survives restarts.

2
Custom adapters

When sandboxMemory isn't enough

Swap the adapter when your application needs cross-session user memory, TTL-based cache eviction, vector or full-text search, or shared tenant-level context. The session API stays identical — only the adapter behind it changes.

NEED

User memory survives session restart

SOLUTION

External adapter (Redis, Postgres, vector DB)

NEED

Semantic search across stored memories

SOLUTION

Adapter that declares memory.search (vector-backed)

NEED

Auto-expire cached tool results

SOLUTION

Adapter that declares memory.ttl

NEED

Tenant-level shared context

SOLUTION

Adapter that supports tenant scope

Registering a custom adapter

External memory adapter typescript
import { defineHarness } from '@purista/harness'
// Implement MemoryAdapter or use a community adapter.
// See the MemoryAdapter contract below for the interface to implement.
import { MyRedisMemoryAdapter } from './myRedisMemoryAdapter'

const harness = defineHarness({ name: 'my-service' })
  .memory(new MyRedisMemoryAdapter({ url: process.env.REDIS_URL! }))
  .requires(['memory.session', 'memory.user', 'memory.ttl', 'memory.search'])
  // .requires() fails at build time if the adapter doesn't declare these
  .models({ ... })
  .build()
3
Expert: implement your own

The MemoryAdapter contract

External adapter packages belong in @purista/harness-memory-*. The core harness owns validation, telemetry wrapping, error normalisation, and content-capture policy. Your adapter implements backend I/O only.

Adapter interface

Adapter shell typescript
import type { MemoryAdapter, MemoryStore } from '@purista/harness'

export function myMemoryAdapter(): MemoryAdapter {
  return {
    info: {
      id: 'my_memory',
      packageName: '@myorg/harness-memory-custom',
      capabilities: [
        'memory.session',
        'memory.run',
        'memory.user',
        'memory.tenant',
        'memory.ttl',
        'memory.search',
      ],
    },

    get capabilities() { return this.info.capabilities },

    async open(scope, ctx) {
      // return a MemoryStore for this scope
      return buildStore(scope, ctx)
    },

    async close() {
      // release connections
    },
  }
}

MemoryStore operations

MemoryStore implementation typescript
function buildStore(scope, ctx): MemoryStore {
  return {
    async get(key, opCtx) {
      opCtx.signal.throwIfAborted()
      return backend.get(namespace(scope, key))
    },

    async set(key, value, opCtx) {
      opCtx.signal.throwIfAborted()
      const ttl = opCtx.opts?.ttlMs
      await backend.set(namespace(scope, key), value, { ttl })
    },

    async delete(key, opCtx) {
      opCtx.signal.throwIfAborted()
      await backend.delete(namespace(scope, key))
    },

    async list(opCtx) {
      opCtx.signal.throwIfAborted()
      return backend.list(namespace(scope, opCtx.opts?.prefix ?? ''))
    },

    // optional: only implement when capability declared
    async search(query, opCtx) {
      opCtx.signal.throwIfAborted()
      return backend.vectorSearch(namespace(scope, ''), query)
    },
  }
}
Respect ctx.signal

Call signal.throwIfAborted() at the start of every backend operation. Pass it into any async SDK calls that accept it.

Backend I/O only

Core owns telemetry wrapping, error normalisation, and content-capture enforcement. Don't duplicate those in your adapter.

Pass the contract tests

Use memoryAdapterContract from @purista/harness/testing before publishing. It validates scope isolation, read/write round-trips, and list behaviour.

Compare prompts before you ship.

The eval helpers let you run deterministic prompt comparisons locally — no external platform needed.