Skip to content

Memory: the ledger and the memory server ​

@agent-custody/state is a bitemporal fact ledger and an MCP server over it. Behind the gateway, every write to memory is a receipted tool call with an attested actor; a fact carries its provenance, its validity, the receipt that wrote it, and every store it was written through to. Retraction, erasure with a certificate, legal holds, and retention are events on the same history.

Running the server ​

bash
agent-custody-memory serve --ledger ledger.sqlite [--key memory.key] [--forget-key-env FORGET_KEY] [--retention 'org=P365D,team:*=P90D'] [--allow-direct]
agent-custody-memory serve --ledger postgres://… --http --port 8790 --host 127.0.0.1 --token-env MEMORY_TOKEN

Over stdio it is the gateway's upstream ("upstream": { "command": "agent-custody-memory", "args": ["serve", "--ledger", "ledger.sqlite"] }); over HTTP several gateways share one ledger ("upstream": { "url": "http://memory:8790/mcp", "tokenEnv": "MEMORY_TOKEN" }). The ledger is a JSONL file, SQLite when the path ends in .sqlite or .db, or a postgres:// URL (needs the pg package; ?table= names the table). By default the server refuses calls that did not come through the gateway; --allow-direct accepts them as claimed and quarantines what they write. --key signs every result for its receipt, so memory executions verify as attested. --forget-key-env keys forgotten values' digests with a secret held outside the ledger.

In code: createMemoryServer(ledger, { requireGateway?, stores?, identity?, retention?, verify?: { attempts?, delayMs? } }): Server (an MCP Server to connect to any transport) and serveMemoryHttp(ledger, { port, host?, tokens?, ...same }).

The tools ​

Every tool returns its JSON as one text content item; a refusal is isError: true with the reason as text. The gateway sets _meta["agent-custody/receipt"], agent-custody/agent, and agent-custody/observed on each call; the agent cannot. A fact:

json
{ "factId": "70cea8ca-…", "subject": "acct:42", "predicate": "plan", "value": "enterprise", "space": "org", "actor": "support-agent", "source": { "receiptId": "2814…" }, "provenance": "attested", "validFrom": "2026-09-08T18:26:04.449Z", "validTo": null, "confidence": null, "external": { "mem0": "mem_7" } }

provenance is claimed (a direct writer said so; quarantined), attested (written through the gateway under a signed grant), or verified (attested, and the value equals what the gateway itself fetched). A forgotten fact has value: null and forgotten: { valueDigest, digestKind, at }.

memory.write ​

Request { "subject", "predicate", "value", "space", "actor"?, "validFrom"?, "confidence"?, "supersedes"?, "evidence"?: { "fact", "path"? } }

supersedes names the fact this one replaces, which stops being believed. evidence names a fact the gateway fetched for this call (a configured lookup); the value must equal it, or the field at path, and the write is then verified; otherwise it is refused. Through the gateway the actor is the grant's agent regardless of actor.

Response { "fact": {…}, "eventId": "…", "txTime": "…", "supersedes": "<factId>" | null }

memory.read ​

Request { "subject"?, "predicate"?, "space"?, "validAt"?, "txAt"?, "includeClaimed"?, "requireVerified"? }

validAt asks what was true then; txAt asks what the ledger knew then; both default to now. Quarantined facts are left out unless includeClaimed; requireVerified returns only verified ones.

Response { "facts": [ {…}, … ] }, and _meta["agent-custody/facts"] listing the ids served, which the gateway records as consumed on every later receipt of the session.

memory.confirm ​

Lifts a quarantined fact to attested. Gateway only. Request { "factId" } → Response { "eventId", "factId", "txTime", "actor", "source" }.

memory.retract ​

The fact leaves the present, stays visible to questions about the past, and whatever it superseded is believed again. The ledger is retracted first, then every store. Request { "factId", "reason", "actor"? } → Response { "eventId", "factId", "txTime", "actor", "reason", "source", "removedFrom": ["mem0"], "verification": { "mem0": "verified" } }. A store that still holds the value makes the result isError with the reason and the same JSON second.

memory.forget ​

Erases the value from the ledger, keeping its digest (keyed when the server has a forget key; none when keepDigest is false), and from every store; the fact stops being believed. The receipt of this call is the certificate. Request { "factId", "reason", "keepDigest"? } → Response { "factId", "valueDigest", "digestKind": "sha256" | "hmac-sha256" | "none", "txTime", "actor", "reason", "source", "erasedFromLedger": true, "removedFrom": [...], "verification": { "<store>": "verified" | "stillIndexed" | "unverified" | "failed" } }. Refused while a hold stands.

memory.hold, memory.release ​

A legal hold refuses forget and sweep until released. Request { "factId", "reason" } → Response the hold or release event { "eventId", "kind": "hold" | "release", "txTime", "factId", "actor", "reason", "source" }.

memory.sweep ​

Retention: forgets every fact the ledger learned of before an instant, in one space or all, skipping held facts. Request { "reason", "before"?, "space"?, "keepDigest"? }; without before, the server's --retention windows decide per space. Response { "before", "retention", "space", "forgotten": [{ "factId", "valueDigest", "digestKind", "removedFrom", "verification" }], "held": ["<factId>", …] }.

memory.get, memory.history ​

memory.get { "factId" } → the fact with retracted: boolean, null fields omitted, for the gateway's own fact lookups so policy can decide on the fact a write supersedes. memory.history { "factId" } → { "events": [ …oldest first ] }, each an assert, confirm, retract, forget, hold, or release event as above.

The Ledger in code ​

ts
import { Ledger } from "@agent-custody/state";
const ledger = new Ledger("ledger.sqlite", { forgetKey?: process.env.FORGET_KEY });
methodrequestresponse
assert({ subject, predicate, value, space, actor, source?, provenance?, external?, validFrom?, confidence?, supersedes?, factId? })AssertEvent { eventId, kind: "assert", txTime, fact, supersedes }
asOf({ validAt?, txAt?, space?, subject?, predicate?, include?: "attested" | "verified" | "all" })Fact[] believed at that moment
retract({ factId, actor, reason, source? })RetractEvent
confirm({ factId, actor, source? })ConfirmEvent
forget({ factId, actor, reason, source?, keepDigest? })ForgetEvent { …, valueDigest, digestKind }
hold(input), release(input), held(factId){ factId, actor, reason, source? }HoldEvent, boolean
sweep({ before, space?, actor, reason, source?, keepDigest? }){ forgotten: ForgetEvent[], held: string[] }
get(factId), history(factId), facts(), spaces(), count(), learnedBefore(before, space?)a fact, its events, every fact, the space names, the event count, the facts learned before an instant
compact(), close()SQLite checkpoints the WAL, Postgres runs VACUUM FULL; releases the store

The event stores behind it, JsonlStore, SqliteStore, and PostgresStore, implement one EventStore interface; openStore(location) picks by path.

Write-through stores ​

Every write lands in each configured store with custody metadata (factId, space, actor, provenance, receiptId, validFrom, source: "agent-custody"), the store's own id is recorded on the fact under external, and a retraction or forget reaches the store by that id; then the store's own search is asked whether the value is gone.

ts
interface Store { name: string; put(fact): Promise<string>; remove(externalId, fact): Promise<void>; verifyRemoved?(externalId, fact): Promise<boolean> }
adapterconstructornotes
mem0Store(client, { userId, infer? })a mem0ai MemoryClientinfer off by default, so the memory is the fact verbatim
zepStore(client, { userId } | { graphId })a @getzep/zep-cloud clientone episode per fact
pgvectorStore(client, { embed, dimensions, table, topK? })a pg poolone row per fact keyed by the fact id; removal verified by nearest-neighbour search
lettaStore(client, { agentId, tags? })a @letta-ai/letta-client Lettaarchival passages, custody as tags
langgraphStore(store, { namespace })any LangGraph BaseStoreone item per fact keyed by the fact id; where LangMem keeps its memories
cogneeStore({ url, datasetId, apiKeyEnv? | tokenEnv? })Cognee's REST APIno client package exists; the id is found by listing the dataset