Skip to content
← Blog

Engineering · July 2, 2026 · 9 min read

Agent memory on Postgres: how we built the write path

A team of agents writes in bursts — a single run can emit a hundred events in ten seconds. The write path can't stop to think. Here's how we're building one on Postgres that stays fast, guarantees read-your-writes, and settles conflicts with a constraint instead of an LLM.

The write path can't wait for an LLM

Turning a raw agent event into a clean, versioned memory takes an LLM — extraction. LLMs are slow and cost money per call. So the tempting design, "run extraction on every event before acknowledging the write," is a trap: the write path inherits the model's latency, a bursty run becomes a storm of calls, and cost grows linearly with traffic.

So extraction is asynchronous. The write path does the cheap, fast thing — durably record the event and return — and a background worker does the expensive thing later. That decision is correct, and it immediately creates the hardest problem in the whole system: if extraction hasn't run yet, and agent B asks for context 200ms after agent A wrote, the memory isn't there yet. The product's entire reason to exist — "B sees what A did" — fails in the most common case. Everything below is how we close that gap without putting an LLM on the write path.

Sequence numbers: the cheap trick behind read-your-writes

The backbone is a per-run, server-assigned, monotonic sequence number. When an event is written, it gets a seq from a single atomic row update — no locks, no coordination:

-- one atomic increment per run; returns the new seq
UPDATE runs SET last_seq = last_seq + 1
WHERE run_id = $1
RETURNING last_seq;

The write responds with { event_id, seq }, and the SDK carries that seq on the run handle. Now consistency is something you can ask for. A context request takes an optional min_seq, and the pack it returns includes two lanes: the extracted memories, plus a raw tail — a labeled section of the still-unprocessed events after covered_seq. So agent B always sees agent A's write: distilled if extraction finished, raw if it didn't. The response carries covered_seq and freshness_lag_ms, so the client knows its consistency state instead of guessing. That's read-your-writes for agents, and it's a feature we can put in an API contract — not a promise in a blog post.

Conflicts: a constraint, not a model call

"Task X is done" and "Task X is in progress" contradict each other. The naive fix — ask an LLM "does this new memory conflict with any existing one?" — is expensive, non-deterministic, and impossible to audit. So detection is structural. Extraction emits, alongside the free text, a structured claim: (entity, predicate, value, event_time). Conflict becomes a definition, not a judgment: the same (entity, predicate) with a different value. And "one current truth" lives as a database constraint:

-- at most one live claim per (entity, predicate)
CREATE UNIQUE INDEX one_current_truth
  ON claims (project_id, entity_id, predicate)
  WHERE superseded_by IS NULL;

When a conflict trips the constraint, a policy engine resolves it — last-writer-wins, field merge, an LLM adjudicator, or a human review queue — and the losing value is superseded, not deleted. The LLM only ever adjudicates; it never detects. Detection is the database's job, and databases don't hallucinate.

Making concurrent writes safe

Two workers processing two batches that both touch the same entity is a recipe for duplicate memories and broken version chains. Optimistic locking catches a write-write collision but misses the insert race — where both workers check, see nothing, and both insert. So the consolidation critical section is serialized per entity with a transaction-scoped advisory lock:

SELECT pg_advisory_xact_lock(
  hashtextextended(project_id::text || ':' || entity_key, 42)
);

Because the lock is keyed on the entity, work on different entities still runs fully in parallel — the lock buys safety without buying a global bottleneck. Add an idempotency key on candidate memories (the event-batch hash) and a retried job can't double-produce. Correctness is enforced by the lock and the key, not by hoping the scheduler cooperates.

Keeping cost down — in the async lane, where it belongs

With correctness pinned to the database, cost optimization moves entirely to the background. Four layers keep extraction_calls / event under 0.15:

Why Postgres for all of it

It's one Go binary, Postgres (with pgvector and pg_search), and Valkey for the hot lane. No graph database, no external queue — even the job queue lives in Postgres. That keeps self-hosting to a single docker run, but it also reflects a design principle worth stating plainly:

Bury the correctness guarantees in the database and protocol layer — constraints, locks, sequence numbers, row-level security. Push the cost optimizations to the async layer — coalesce, batch, cache. And tie every promise to a metric.

That's the whole philosophy of the write path. The guarantees are the part you can't fake — so they live where they can be enforced, not where they can be forgotten. Access control compiles to a SQL predicate; visibility is a sequence number; "one current truth" is a unique index. When what you sell is other people's consistency problem, "looks like it works" and "provably works" are different products.

The one-line version

A multi-agent write path on Postgres works when the fast path never touches an LLM, read-your-writes rides on a monotonic sequence number, and conflicts are caught by a constraint and resolved by policy. Correctness in the database; cost in the background; a metric on every promise.

We're building this in the open — the write-path and read-your-writes design live as RFCs in the repo. See the shape of it on Features, or the category it serves in the pillar. Repo & RFCs on GitHub.

Everything falls into one shared reality.

Lore

Join the waitlist

Early access + design-partner slots. No spam — just the first ping when it opens.