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.
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.
The ordered message log for the current session. The harness feeds the configured historyWindow into each new model request. Persisted through the StateStore.
const messages = await session.history.list({ limit: 20 })
// returns: Message[] — role, content, timestamps 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.
await session.memory.write('last-topic', { topic: 'pricing' })
const prior = await session.memory.read<{ topic: string }>('last-topic')
// -> { topic: 'pricing' } 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.
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.
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.
// 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.
// 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.
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.
const handle = ctx.memory.scope({
kind: 'user',
userId: 'u_123',
tenantId: 't_acme',
})
await handle.write('onboarding-complete', true) 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.
// 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
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.
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 — 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 memory.search not declared) 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.
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.
User memory survives session restart
External adapter (Redis, Postgres, vector DB)
Semantic search across stored memories
Adapter that declares memory.search (vector-backed)
Auto-expire cached tool results
Adapter that declares memory.ttl
Tenant-level shared context
Adapter that supports tenant scope
Registering a custom adapter
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() 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
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
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)
},
}
} Call signal.throwIfAborted() at the start of every backend operation. Pass it into any async SDK calls that accept it.
Core owns telemetry wrapping, error normalisation, and content-capture enforcement. Don't duplicate those in your adapter.
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.