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:
- Coalescing. Extraction runs per run, debounced: process when a run goes quiet for a couple of seconds, or once it accumulates ~20 events. One call, twenty events, full context — and roughly a 20× cut in calls.
- Gating. Not every event deserves an LLM. Cheap heuristics — payload size, kind, a regex — route tool logs and stack traces straight to "archive, don't extract." The goal is to drop 40–60% of events before a model ever sees them.
- Prompt caching. The extraction prompt puts the fixed blocks (system prompt, project schema) first and the variable events last, so cache hits do most of the work. Same discipline as the deterministic pack: fixed first, variable last.
- A batch lane. In economy mode, coalesced jobs go to a batch API — half the cost, minutes of latency accepted. Freshness becomes a per-project dial, which doubles as a pricing lever.
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.