Or: what happens to an AI agent's chat history when you stop treating it like a JSON blob.
Dagr is a schema-driven binary serialization format: you describe your data once and it generates zero-dependency reader/writer libraries in Swift, Rust, TypeScript, and more. This is part of a series on what it can do — the series index lists every post. In Let's talk about structured logging I promised case studies of DataSink cutting size and improving performance in real applications. This is the first one, and it is a big one: Zed, a production code editor written in Rust.
Most of this series demonstrates Dagr on toy schemas. That is fine for explaining a wire format, but it never answers the question people actually ask: does this hold up inside a real application that someone else designed, under constraints I did not get to choose?
So we took a fork of Zed and replaced its persistence layer with Dagr. Zed is a good stress test — it is a fast, native editor, it already stores several kinds of state (workspace layout, editor state, agent threads) in SQLite, and its authors clearly care about performance. We wired Dagr into workspace layout and editor state too, but the interesting one — the one that turned into a small research project — is the agent thread store: the history of your conversations with the built-in AI coding agent.
Agent history is a nasty little data problem hiding behind a boring UI. Here is why.
- It grows without bound. A single agent thread can run for hundreds of turns. Each turn carries user text, agent text, "thinking" blocks, and — the heavy part — tool calls and their results. In the real threads we measured, tool results plus thinking routinely made up 70%+ of a thread's bytes.
- You open it far more often than you read it. Clicking a thread in the history list should show you the last screenful. That is maybe twenty entries. But to show twenty entries the naive design has to load and parse all of them.
- Most of the record is write-once and never read back. Zed attaches a
ProjectSnapshotto each thread for telemetry. It is marked#[allow(unused)]— written once, never read on open, never sent to the model. On short threads it can be 99.9% of the stored bytes. In one real row it was a 4.4 MB snapshot attached to a 2-message thread.
Zed's original design stores each thread as a single zstd-compressed JSON blob in a SQLite row. JSON is the reasonable default, and for small threads it is totally fine. But it forces one shape on data that has three very different access patterns, and it makes you pay for the whole thing every time you touch any of it. That is the itch we wanted to scratch.
The first change is the least clever and still worth it. Instead of serializing the thread to JSON and compressing that, we describe the thread as a Dagr schema and store a native Dagr blob (still zstd on top). Same SQLite table, one new data_type tag so old JSON rows keep loading, with a fallback to JSON if a Dagr encode ever fails. On a real five-message thread from a live agent turn the whole thing came out to 1.6 KB.
But a straight blob-for-blob swap misses the point. The reason to move off JSON is not that binary is smaller — it is that once you have a schema, you can shape the storage to the data instead of the other way around. Two possibilities opened up: splitting the data by how it is actually used, and modeling its relationships as real edges. The first carried the final design. The second we tried, liked, and then deliberately gave up — an instructive story I'll get to at the end.
We mapped every consumer of a stored thread (three passes through Zed's agent crate) and found three cleanly separable groups:
- Metadata — title, timestamps, model, token totals. Small, rewritten on every save, read on every list render.
- The telemetry snapshot — large, write-once, never read on open.
- The message stream — the growing list of turns. Read fully only when building the next model request, and even then only the tail since the last compaction.
So we stopped storing one blob and started storing three, each in its own column with its own lifecycle:
- Metadata → a tiny DataGraph rewritten per save.
- Snapshot → an opaque write-once blob that the open path never selects. Opening the 4.4 MB-snapshot / 2-message thread now reads none of those 4.4 MB.
- Messages → an append-only DataSink.
That last one is the heart of it, and it is exactly the case DataSink was built for. A thread is a stream of records of similar shape that you append and never mutate — structured logging by another name. Saving a new turn appends to the sink instead of re-serializing the whole history. The O(n²) trap of "load array, push one, re-serialize array" that I described in the structured logging post is gone by construction.
Here is the trick that pays for the whole redesign. When you open a thread, Zed's UI is already virtualized — it renders only the visible window of entries through a gpui list(). So the storage layer should only have to produce that visible window. It never needed all the messages; the old design just had no way to avoid decoding them.
With the messages in a DataSink, it does. We made the message history lazy: on open we decode zero message bodies. We read the metadata, count the records by skipping over their byte-size prefixes (no body parse), and hand back a windowed view. Only the ~40 messages the UI actually shows get decoded, via a windowed replay of the sink tail. Browse a thread and close it again and you have decoded almost nothing.
Two more refinements made "the tail" genuinely cheap rather than merely smaller:
- Block compression. Instead of one zstd frame over the whole message blob, we slice the sink into independently compressed ~128 KB blocks. Opening the tail decompresses only the tail block. Because every DataSink record is self-describing and carries its own byte length, we can byte-copy records into blocks without re-encoding them.
- A doubly-linked sink for reverse walks. The model request builder needs the history backwards from the end to the last compaction, plus a byte-budgeted prefix of recent user messages. We declared the sink
doubly_linked(each record also ends with its length in reversed LEB, as covered in the logging post), so the builder walks newest-first and stops at the last compaction — it never scans the old history at all.
The numbers, measured over ~100 real threads (average 549 messages / 1.3 MB each):
| Open path | Time | vs. old |
|---|---|---|
| Old: decode whole blob | 5.09 ms | 1× |
| Lazy, single-frame zstd | 3.75 ms | 1.4× |
| Lazy + block-compressed tail | 0.573 ms | ~9× |
And because the visible-window cost is now independent of thread length, a synthetic sweep shows it flattening to roughly a constant ~16 µs to project the window, while the old decode-everything path climbs to ~24 ms on a 4,000-message thread — a gap that keeps widening the longer you talk to the agent. The storage cost of opening a conversation stopped scaling with how long the conversation is.
The trade-off is honest: block compression makes the stored blob about 6% larger (609 KB vs 572 KB in the corpus) because you compress in chunks rather than as one stream. Paying 6% on disk to make every open ~9× cheaper was an easy call.
The read side gets the headlines, but the write side is where the append-only sink quietly changes the complexity class. Here is the thing about the original design that is easy to miss: an agent thread is saved constantly — after every user message, every streamed agent turn, every tool result. And each of those saves re-serialized the entire thread to JSON and re-compressed the whole thing with zstd. That is O(n) work on every save, so building a thread up to n messages over its lifetime costs O(n²) total. It is the exact O(n²) trap from the structured logging post, living inside a code editor.
The DataSink turns each save into an append: serialize the one new record, tack it on, done. O(1) per save, O(n) over the thread's life. In production this is the IncrementalSplit encoder holding the growing sink buffer across saves and, for the compressed form, sealing and zstd-ing a block only when it crosses a 64 KB boundary — so compression amortizes instead of running from scratch every time.
I measured it with a benchmark that builds a thread up to n messages, saving after each one — the realistic pattern — with the same zstd compressor on both sides so it is apples-to-apples:
| Thread length | Dagr append (block-compressed) | Zed's re-serialize + recompress | Write-CPU speedup |
|---|---|---|---|
| 50 messages | 47 µs | 1.3 ms | 28× |
| 200 messages | 176 µs | 16.9 ms | 96× |
| 1,000 messages | 1.0 ms | 426 ms | 424× |
| 4,000 messages | 4.1 ms | 7.2 s | 1,753× |
Like the open cost, this is not a fixed multiplier — it widens with thread length, because one side is linear and the other is quadratic. On a 4,000-message thread the old pattern spends over seven seconds of cumulative CPU across its life just re-compressing history it already compressed a thousand times; the sink spends about four milliseconds.
One honest deflation, so nobody quotes the wrong number. Dagr's encoder is only about 1.4–1.6× faster than serde_json at encoding the same content once. The three-orders-of-magnitude figure above is not the encoder being magic — it is the sink not redoing work. The win is algorithmic, not micro-optimization, which is exactly why it grows with n. And it costs nothing on disk: with the same compressor, the block-compressed sink is 0.90–0.96× the size of the JSON-zstd blob.
Splitting by access pattern was the win. But there was a second, more seductive idea, and I want to be honest about it, because we built it, measured it, and then gave it up.
Dagr nodes can reference each other: a referenced node is stored once and pointed at from many places. JSON has no pointers — it can only nest or duplicate. Zed's thread is full of relationships JSON can only fake. A tool call and its result are linked by a shared id that both records carry and nothing enforces. The tool names read_file and edit_file repeat hundreds of times; so do the same file URIs. Every token-usage record echoes back a user-message id.
In the first version of this work — when the whole thread was one DataGraph blob — we modeled all of that as real edges. tool_result referenced the tool_use node instead of carrying a loose string id. Repeated tool names and URIs were interned into single shared nodes pointed at from everywhere they occurred. A test encoding 200 mentions of the same URI produced a dramatically smaller blob than 200 distinct ones — losslessly, with no compression pass doing the work. It was lovely, and it was exactly the "graph, not document" pitch.
Then the split design moved the message stream into a DataSink — and a DataSink cannot do any of that. This is not a bug; it is the definition of a sink. Its whole value is O(1) append and windowed reads: each record is serialized once, on its own, and never touched again. A node reference is a pointer within a single serialized unit, so a record appended today cannot point at a node inside a record written an hour ago. Cross-record node-sharing and append-only streaming are mutually exclusive — you get to pick exactly one.
We picked streaming, and it was not close. Interning saved bytes, but zstd over the sink recovers most of those same repeated bytes anyway, and the windowed open plus the reverse request-builder are worth far more than interning ever was. So the shipped schema deliberately uses local string ids where the single-blob version used edges (tool_result.tool_use_id, the token record's user_message_id), and repeated names and URIs are stored inline. The schema comments say it out loud: # inline (no cross-record intern).
The lesson is the one I did not expect going in. The graph really is strictly more expressive than a document — the pointers are real, JSON has none, and for a single self-contained blob they are a genuine free win. But expressiveness collides with access pattern. We had a format powerful enough to model every relationship in the thread, and the right call was to spend that power on streaming instead. The payoff of a schema-driven format isn't that it forces one clever trick on you — it's that it hands you both tools, the graph and the sink, and lets you choose which one each part of your data actually wants.
A note in the spirit of honesty, because I chased a ghost here. Partway through, a benchmark suggested that opening a thread was quadratic in its length, and I got excited about "removing a quadratic." It was not real — it was an artifact of gpui's leak-detector, which is compiled into test builds but not production builds, inflating entity-allocation cost as the live count grew. In a real build, opening is linear. I measured it three different ways before I believed it. The lazy-window win above is real and stands on its own; the quadratic was mine to un-claim. Measure before you brag.
The whole change lives on a branch of my Zed fork so you can read exactly what it touched — the schema, the codec, the SQLite wiring, and the lazy read path — against upstream:
github.com/mzaks/zed → main...dagr-persistence
The generated Dagr codecs are a dependency-free dagr_persist crate; everything else is ordinary Rust changes in Zed's agent, workspace, and editor crates. It builds, and the existing thread-store tests pass against the new backend.
The lesson I took away: a serialization format earns its keep not when it makes your blob smaller, but when it lets you stop treating differently-used data the same way — and when it gives you more than one shape to store it in, so you can match each part of your data to the access pattern it actually has. Agent history is a small feature in a big editor, but it has all three of the properties that make storage hard, and a schema-driven format that offers both a graph and a sink had an answer for each one.
PS: docs, examples, and interactive demos live at dagr.one.