<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nicolas Primeau</title>
    <description>The latest articles on DEV Community by Nicolas Primeau (@nimbus_data).</description>
    <link>https://dev.to/nimbus_data</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3830345%2F64a049c4-3656-485a-8274-b61d57e724f1.jpg</url>
      <title>DEV Community: Nicolas Primeau</title>
      <link>https://dev.to/nimbus_data</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nimbus_data"/>
    <language>en</language>
    <item>
      <title>Building a CRDT-replicated memory mesh for AI agents</title>
      <dc:creator>Nicolas Primeau</dc:creator>
      <pubDate>Thu, 28 May 2026 02:19:41 +0000</pubDate>
      <link>https://dev.to/nimbus_data/building-a-crdt-replicated-memory-mesh-for-ai-agents-5d5d</link>
      <guid>https://dev.to/nimbus_data/building-a-crdt-replicated-memory-mesh-for-ai-agents-5d5d</guid>
      <description>&lt;p&gt;Every multi-agent setup I tried ran into the same wall: the agents couldn't remember anything together.&lt;/p&gt;

&lt;p&gt;Each Claude Code session started cold. Two agents working the same repo had no idea what the other had done. The "shared context" I kept building turned into a graveyard of half-finished orchestration scripts. Eventually I gave up on the orchestrators entirely and built the missing piece: a server that gives a fleet of agents a shared, semantic memory and coordination layer, with no central coordinator and no framework lock-in.&lt;/p&gt;

&lt;p&gt;This post is about the part I think is most interesting: how the memory replicates between instances as a CRDT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;A fleet of agents is not the same as a single agent with sub-agents.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sessions die. The next one needs to pick up where the last one left off, possibly on a different machine.&lt;/li&gt;
&lt;li&gt;Multiple agents on different hosts are doing related work. They shouldn't duplicate, shouldn't overwrite, shouldn't collide.&lt;/li&gt;
&lt;li&gt;There's no single process you can pin all the state to. A bot on my laptop, a bot in CI, and a bot on a colleague's machine should &lt;em&gt;all&lt;/em&gt; be able to see the same memory.&lt;/li&gt;
&lt;li&gt;The agents aren't all the same framework. One is Claude Code. One is an AutoGen script. One is a cron job calling the API directly. They have nothing in common except HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The obvious answer ("put everything in a central database") works until you need offline-ish behavior, or you don't want every team's data flowing through one shared server, or you want a peer-to-peer story between hosts.&lt;/p&gt;

&lt;p&gt;What I wanted instead: each host runs its own server with its own SQLite database. Pairs of servers can &lt;em&gt;link&lt;/em&gt;, and from then on their memory replicates in both directions, automatically. No quorum. No leader election. No central anything.&lt;/p&gt;

&lt;p&gt;This is a classic CRDT problem.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdj5lz7ju5qbv9fpiq5xc.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdj5lz7ju5qbv9fpiq5xc.gif" alt="Two Artel instances meshing: memory written on one shows up on the other and vice versa, no central coordinator" width="480" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CRDTs, and which one
&lt;/h2&gt;

&lt;p&gt;The constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No central coordinator.&lt;/strong&gt; Each instance writes locally, reads from peers asynchronously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convergence.&lt;/strong&gt; Two instances that have seen the same set of operations must end in the same state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency.&lt;/strong&gt; Ingest the same entry twice → same result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No infinite loops.&lt;/strong&gt; A → B → A → B should terminate, not echo forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The data is a set of memory entries (think notes with content, tags, confidence, and a few metadata fields). Some get updated, some get tombstoned. There's no real "merge" semantics beyond last-write-wins per field.&lt;/p&gt;

&lt;p&gt;I picked an &lt;strong&gt;LWW-Element-Set CRDT&lt;/strong&gt;. It's formally proven convergent for exactly this kind of grow-and-tombstone data. The implementation has three pieces that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Entries are keyed by immutable origin UUID.&lt;/strong&gt; Every memory entry has an &lt;code&gt;id&lt;/code&gt; minted at creation time, on the origin instance. When that entry replicates to a peer, the peer stores it with the &lt;em&gt;same&lt;/em&gt; ID. No re-minting on ingest. That's what makes idempotency cheap: &lt;code&gt;INSERT OR REPLACE&lt;/code&gt; is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The merge rule is &lt;code&gt;max(version)&lt;/code&gt;, with &lt;code&gt;updated_at&lt;/code&gt; as tiebreak.&lt;/strong&gt; Each entry has a monotonic &lt;code&gt;version&lt;/code&gt; counter that the origin increments on every update. Peers compare &lt;code&gt;(version, updated_at)&lt;/code&gt; tuples. Last-write-wins, but the version dominates so wall-clock skew between hosts almost never matters in practice. (Equal-version plus skewed-clock is the one theoretical edge case; in three months of running this across machines I haven't hit it.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The origin guard prevents loops.&lt;/strong&gt; Every entry carries an &lt;code&gt;origin&lt;/code&gt; field: the ID of the instance that minted it. When ingesting from a peer, we skip any entry whose origin matches our own. So if A pushes an entry to B, and B's feed echoes it back to A, A drops it. A→B→A terminates after one round-trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transport: just feeds
&lt;/h2&gt;

&lt;p&gt;The replication protocol is &lt;em&gt;not&lt;/em&gt; a custom binary thing. It's Atom and JSON Feed.&lt;/p&gt;

&lt;p&gt;Each instance publishes its memory at two URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /memory/feed.atom?mesh_token=...
GET /memory/feed.json?mesh_token=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are normal RSS-style feeds, with one twist: the JSON Feed format includes a custom &lt;code&gt;_artel&lt;/code&gt; extension on each item that carries the CRDT metadata (&lt;code&gt;origin&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;parents&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;deleted_at&lt;/code&gt;, and so on). That's what makes the merge work.&lt;/p&gt;

&lt;p&gt;The mesh poller on each instance is just an RSS reader. It polls each linked peer's feed every N minutes, walks the new entries, applies the merge rule, writes to local SQLite. That's the entire replication loop. About 150 lines of Python.&lt;/p&gt;

&lt;p&gt;The choice to use feeds instead of a custom protocol was deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any HTTP client can poll a feed. The mesh is debuggable with &lt;code&gt;curl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Feeds are inherently pull-based, so neither side needs to know the other's address ahead of time except for the initial link.&lt;/li&gt;
&lt;li&gt;The same feeds double as a public Atom/JSON Feed for human readers. The instance's UI can subscribe to its own feed to render a timeline.&lt;/li&gt;
&lt;li&gt;LAN peers discover each other via mDNS (&lt;code&gt;_artel._tcp.local.&lt;/code&gt;). One click in the UI to link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no fancy gossip protocol, no Merkle tree sync, no anti-entropy mechanism. Just polling feeds. It turns out that's enough for the throughput a fleet of LLMs generates. Even an enthusiastic agent only writes a few entries per minute, far below what a feed poller can handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite + sqlite-vec for embeddings
&lt;/h2&gt;

&lt;p&gt;The backing store is SQLite in WAL mode. For semantic search, &lt;a href="https://github.com/asg017/sqlite-vec" rel="noopener noreferrer"&gt;sqlite-vec&lt;/a&gt; provides a virtual table for vector similarity. Embeddings are computed locally with a small model and inserted into a &lt;code&gt;memory_vec&lt;/code&gt; table keyed by the same UUID as the main &lt;code&gt;memory&lt;/code&gt; table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;VIRTUAL&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;memory_vec&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;vec0&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="nb"&gt;FLOAT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search is a join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;memory_vec&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;mv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with conventional filters (tags, project, confidence floor, type), this is fast enough that I haven't needed to think about indexing strategy. The whole memory table is in WAL-mode SQLite; reads don't block writes.&lt;/p&gt;

&lt;p&gt;When a CRDT merge happens (say a peer's version of an entry overwrites the local one), the embedding is re-computed and the &lt;code&gt;memory_vec&lt;/code&gt; row is replaced. The semantic search index is always in sync with the merged content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The archivist: a background agent that keeps memory healthy
&lt;/h2&gt;

&lt;p&gt;The other piece of the design I didn't expect to need is what I ended up calling the &lt;em&gt;archivist&lt;/em&gt;: a long-running background process that watches all activity and does maintenance.&lt;/p&gt;

&lt;p&gt;It does five things, on a loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Synthesis.&lt;/strong&gt; Reads recent memory entries, looks for related ones, asks an LLM to write a connecting doc that summarizes the cluster. The connecting doc is just another memory entry, tagged appropriately, so it shows up in future searches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup / merge.&lt;/strong&gt; When a new entry comes in that looks semantically very close to an existing one (cosine distance below a threshold), it tries to merge them. The merge is again handed to an LLM with both contents and a "produce a unified version" prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decay.&lt;/strong&gt; Entries that haven't been re-written in a while have their &lt;code&gt;confidence&lt;/code&gt; field lowered. Frequently-read entries are exempt. Below a floor, entries vanish from default searches (they're still there if you ask for low-confidence entries explicitly).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promotion.&lt;/strong&gt; Entries that have been stable, frequently-read, and confidence-high for long enough get promoted from &lt;code&gt;type="memory"&lt;/code&gt; to &lt;code&gt;type="doc"&lt;/code&gt;, which exempts them from decay and treats them as canonical reference material.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project briefs.&lt;/strong&gt; For each project, it maintains a short prose summary of "what's going on here" as a &lt;code&gt;doc&lt;/code&gt;-typed entry tagged &lt;code&gt;project-brief&lt;/code&gt;. The brief is surfaced automatically at the start of every agent session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The archivist is just another agent. It has its own credentials, polls the event stream, makes API calls. There's nothing privileged about it from the server's perspective other than role-based access (the server enforces that the archivist can't &lt;em&gt;create&lt;/em&gt; new projects from thin air; it can only touch projects that already have external presence).&lt;/p&gt;

&lt;p&gt;This separation matters for two reasons. First, you can run an instance without an archivist if you don't want LLM-driven synthesis. The core memory store works fine on its own. Second, the archivist is &lt;em&gt;replaceable&lt;/em&gt;. If you don't like the synthesis prompts or the decay heuristics, you can write your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity and protocol: HTTP all the way down
&lt;/h2&gt;

&lt;p&gt;Agents authenticate with an &lt;code&gt;agent_id&lt;/code&gt; string and an API key. That's the entire identity model. No framework coupling, no SDK to install, no transport pinned. The server speaks plain HTTP, with an MCP adapter on top.&lt;/p&gt;

&lt;p&gt;A Claude Code session connects via an MCP plugin and sees the API as a set of tools (&lt;code&gt;memory_write&lt;/code&gt;, &lt;code&gt;memory_search&lt;/code&gt;, &lt;code&gt;task_claim&lt;/code&gt;, &lt;code&gt;message_send&lt;/code&gt;, and so on). An AutoGen script just calls &lt;code&gt;httpx.post("/memory", ...)&lt;/code&gt;. A bash one-liner with &lt;code&gt;curl&lt;/code&gt; participates in the fleet. The server doesn't know or care what's on the other end.&lt;/p&gt;

&lt;p&gt;That decision came from frustration with how every multi-agent framework wants to be the &lt;em&gt;thing&lt;/em&gt;. You build agents inside CrewAI, or inside LangGraph, or inside MemGPT, and they handle the coordination. Then you can't easily mix them, or use them alongside a hand-rolled script, or replace the framework later. I wanted the coordination layer to be the &lt;em&gt;opposite&lt;/em&gt;: an HTTP server that knows nothing about the agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few honest postmortems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The version field.&lt;/strong&gt; I should have used a vector clock instead of a monotonic counter. The current scheme works because real-world version conflicts on the same entry from two different origins are rare. But "rare" isn't "never," and vector clocks would have made the edge case go away entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings are per-instance.&lt;/strong&gt; Each instance computes its own embeddings with its own model. Two instances with different models would have incompatible embeddings, and semantic search across the mesh would be broken. Right now this is an implicit contract: everyone uses the same default model. A proper fix would either share embeddings as part of the feed, or make the model an explicit per-instance setting that warns on mismatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The archivist is a single point of opinion.&lt;/strong&gt; Decay rates, promotion thresholds, synthesis prompts: they're all hardcoded. They should be configurable per-instance, but right now changing them means editing Python. I'd make this more pluggable next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The whole thing is open source, MIT, and runs in one Docker container. It's been the spine of my own multi-agent setup for a few months now, and the bit I'm proudest of is that &lt;strong&gt;it doesn't enforce a framework on anyone&lt;/strong&gt;. If your agent speaks HTTP, it's in the fleet.&lt;/p&gt;

&lt;p&gt;The project is called Artel. The code is at &lt;a href="https://github.com/NicolasPrimeau/artel" rel="noopener noreferrer"&gt;github.com/NicolasPrimeau/artel&lt;/a&gt; if you want to look. There's a live sandbox at artel.run (password &lt;code&gt;artel&lt;/code&gt;) if you want to poke at the UI before installing anything.&lt;/p&gt;

&lt;p&gt;I'd genuinely love feedback on the CRDT design, especially from anyone who's built distributed memory systems for LLMs and run into corners I haven't yet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sidenote: the agent that helped me draft this post stored an earlier version as a memory entry in the live sandbox so other agents could read it. That's the idea.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
