Steve Yegge’s Beads has garnered a lot of attention since he announced it as an upgrade for agentic memory back in late 2025 and even more so since January of this year when he launched Gastown, his AI coding agent orchestration engine built on top of Beads.
Beads allows agents to persist and manage their own state outside of their context window, which prevents context loss during compaction. Its architecture maps naturally to how coding agents like Claude Code want to structure and interact with their data.
In early February, Steve moved both Beads and Gastown to be exclusively backed by Dolt, which allowed him and his team to scale far beyond what was possible with Beads’s original backend, which was an amalgamation of SQLite and Git.
With Dolt, both Beads and Gastown scaled another order of magnitude, which led Steve to launch Wasteland in March.
Not only have these projects emerged on the engineering scene at lightning speed, but their code changes and feature development move just as fast.
All of these are write-only, fully vibe-coded, open-source projects that receive numerous pull requests from external contributors. This kind of agent-first open-source development may become the new norm, and it’s been an incredibly fun and rewarding journey to participate in and witness firsthand.
But the move to Dolt added friction for solo Beads users, who were used to the simpler SQLite and Git model that didn’t require running an external server. We call these “Beads Classic” users.
These users run Beads as a single process with a single coding agent to upgrade its memory and track issues. They aren’t looking to scale to thousands of agents the way Gastown, Gas City, and Wasteland do. They just want the single-player experience. It was important to Steve that this use-case be preserved despite the full backend migration to Dolt, so we settled on a plan to restore it.
To restore the Beads Classic experience, I opened up my Claude Code terminal and set out to bring this solo-user experience back to Beads as the default, while still using Dolt as the dedicated backend.
Though I’ve been vibe-coding for months myself, this was my first time working on an exclusively vibe-coded production application with this much code and complexity. In my experience, as a codebase grows, the coding agent’s ability to self-direct degrades and hallucinations become much more common.
The best solution I’ve found is for the human to know what they want and how to do it first, then guide the agent to deliver it. If the agent is left to make architectural decisions in large, dense projects, mistakes get made and root causes get hallucinated.
So my approach relied on a specific plan: use Embedded Dolt in place of the external Dolt server, so end users wouldn’t need to know or care about where data was persisted. The tool would just work.
At a high-level, my plan was to first add the new Embedded Dolt backend and design an interface that used the correct access pattern for the database in this mode. Then, I’d add comprehensive test coverage for this backend that ensures it’s safe under a multi-process concurrent workload, even though access to this backend is single-threaded under-the-hood.
Importantly, even though Beads Classic isn’t really meant for multi-process concurrent use, I still had to make sure it was safe under concurrency, because if an agent CAN do something, an agent WILL do it. I had to ensure that if agents in different terminals were running Beads, everything wouldn’t break.
Once I’d completed migrating the storage layer, I’d migrate Beads commands one-by-one to the new backend. This plan would allow me to make incremental progress without shipping gargantuan pull requests. And to avoid interfering with production Beads, all of this work would live behind a Go build tag with explicit error messages.
Adding Embedded Dolt Storage#
The key design was simple: open a connection to the Embedded Dolt engine, run a caller-supplied callback, and close the engine. This ensures that only one caller holds the exclusive write lock at a time. In the event of concurrent callers, they would block until they can acquire the lock themselves or are canceled. The design also ensures the lock is always released because the caller receives a close function alongside the connection.
The signature looks something like this (source):
func OpenSQL(ctx context.Context, dir, database, branch string) (*sql.DB, func() error, error) {
dsn := buildDSN(dir, database)
cfg, err := dollembed.ParseDSN(dsn)
if err != nil {
return nil, nil, err
}
bo := backoff.NewExponentialBackOff()
bo.MaxElapsedTime = 0 // wait until ctx cancellation
bo.MaxInterval = 5 * time.Second
cfg.BackOff = bo
connector, err := dollembed.NewConnector(cfg)
if err != nil {
return nil, nil, err
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxIdleTime(0)
db.SetConnMaxLifetime(0)
cleanup := func() error {
dbErr := db.Close()
connErr := connector.Close()
if errors.Is(dbErr, context.Canceled) {
dbErr = nil
}
if errors.Is(connErr, context.Canceled) {
connErr = nil
}
return errors.Join(dbErr, connErr)
}
// ... USE database, SET head_ref ...
return db, cleanup, nil
}
The caller gets a *sql.DB and a cleanup function and the backoff is configured to wait indefinitely for the engine lock rather than fail, so concurrent callers queue up naturally.
On top of this, I added a helper method called withConn that opens a connection, wraps the caller’s work in a SQL transaction, and commits on writes (source):
func (s *EmbeddedDoltStore) withConn(ctx context.Context, commit bool, fn func(tx *sql.Tx) error) (err error) {
if s.closed.Load() {
err = errClosed
return
}
var db *sql.DB
var cleanup func() error
db, cleanup, err = OpenSQL(ctx, s.dataDir, s.database, s.branch)
if err != nil {
return
}
defer func() {
err = errors.Join(err, cleanup())
}()
var tx *sql.Tx
tx, err = db.BeginTx(ctx, nil)
if err != nil {
err = fmt.Errorf("embeddeddolt: begin tx: %w", err)
return
}
err = fn(tx)
if err != nil {
err = errors.Join(err, tx.Rollback())
return
}
if !commit {
return tx.Rollback()
}
err = tx.Commit()
return
}
Every method on the EmbeddedDoltStore, which is what I called the new storage backend I added to Beads, calls withConn, passes a closure that does the real work inside a transaction, and the engine is opened and closed within the scope of a single method call. The commit parameter lets read-only operations skip the commit and just roll back. This access pattern provides principled access to the underlying database and I used it as the foundation for the new storage (store) type.
Next, I needed a single interface both the DoltStore and EmbeddedDoltStore could implement, which would be the primary abstraction callers would use. So I created the DoltStorage interface with around 86 methods that the existing DoltStore (the server implementation) already satisfied, then added a new EmbeddedDoltStore that would need to satisfy it. To implement the interface quickly, I made all of its methods initially return “Unimplemented”. I find it easier to reason about their implementation later, when migrating the specific commands that call them.
With two implementations behind one interface, I updated all the commands that formerly made calls using the concrete DoltStore type to instead use the DoltStorage interface. Swapping in the embedded backend then became a trivial runtime check, and I could start upgrading commands one at a time.
Migrating Commands#
Claude and I started with the bd init command, which initializes Beads in the repository where it is run. This means creating some state files, initializing the Dolt database, and executing a schema migration so the database has all the required tables.
To do this, Claude and I identified which unimplemented EmbeddedDoltStore methods the init command calls, then implemented those first.
This step made it clear that both store types would end up with duplicate method implementations, which would be difficult to maintain, even for robots. I needed a single place for the shared logic, so I created an issueops package that both stores could call internally.
This shared library made it faster to land new EmbeddedDoltStore methods, but it also let me migrate the existing DoltStore to call into issueops, which cleaned up that code quite a bit.
And because the work was essentially extracting logic from DoltStore into issueops, every extraction was immediately validated against the production backend, not just the new one.
By the end, issueops contained shared implementations for issue creation, retrieval, updates, claims, closures, label management, dependency tracking, and table routing, all the core SQL operations that both stores needed. The functions are transaction-agnostic: they accept a *sql.Tx and don’t care whether it came from a long-lived server connection or a short-lived embedded engine (source).
With the shared library in place, I got back to the init command.
The database initialization and schema migration were straightforward. However, when I added a multi-process concurrency test for bd init on the same workspace (knowing agents will inevitably do this), I discovered a real Dolt bug: Dolt returned a “database exists” error even when running CREATE DATABASE IF NOT EXISTS. I also found a broader limitation: Embedded Dolt’s DDL statements lack atomicity under concurrency, which we can’t fully support today.
As a workaround, I added an exclusive file lock to bd init that makes initialization single-threaded, preventing race conditions during setup. That fix, combined with the Dolt bug fix upstream, got the new init tests passing.
The first and most challenging command after init was the bd create command. This command is the first of the fundamental issue CRUD commands in Beads, which will create a new issue, or “bead”, as they’re called.
For each command, I followed the same workflow: identify which DoltStorage methods the command calls, extract the SQL logic from DoltStore into issueops, update DoltStore to call the shared code, then implement the same methods on EmbeddedDoltStore using that shared code.
This resulted in a fairly smooth workflow. Take GetIssue as an example. Before my work, the DoltStore had all the SQL logic inline in its method:
// DoltStore - before issueops extraction
func (s *DoltStore) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
var issue *types.Issue
err := s.withReadTx(ctx, func(tx *sql.Tx) error {
// ... 30+ lines of SQL queries, label joins, scanning ...
return nil
})
return issue, err
}
I extracted that SQL logic into the issueops shared library as GetIssueInTx, and then the DoltStore became a thin wrapper (source):
// DoltStore - after issueops extraction
func (s *DoltStore) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
var issue *types.Issue
err := s.withReadTx(ctx, func(tx *sql.Tx) error {
var err error
issue, err = issueops.GetIssueInTx(ctx, tx, id)
return err
})
return issue, err
}
And the new EmbeddedDoltStore method calls the same library code, just through its own connection pattern (source):
// EmbeddedDoltStore - same issueops library, different connection lifecycle
func (s *EmbeddedDoltStore) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
var issue *types.Issue
err := s.withConn(ctx, false, func(tx *sql.Tx) error {
var err error
issue, err = issueops.GetIssueInTx(ctx, tx, id)
return err
})
return issue, err
}
The two implementations are nearly identical. The only difference is the transaction wrapper. DoltStore uses withReadTx on a long-lived server connection while EmbeddedDoltStore uses withConn, which opens and closes the engine for each call.
To verify each command worked as intended, I added integration tests covering all flags, configuration options, and multi-process concurrency. At this point, I needed these tests running in CI on pull requests to catch regressions. Claude quickly added a workflow update for that.
The CI infrastructure itself became a meaningful piece of the work. Each command got its own *_embedded_test.go file like init_embedded_test.go, update_close_embedded_test.go, list_embedded_test.go, and so on, which made it easy to run and reason about tests in isolation.
But Embedded Dolt tests are slow. The test binary takes about 3 minutes to build, and I was adding hundreds of integration tests, which each took seconds. To keep CI times reasonable, I had Claude precompile the test binaries and shard them across 20 parallel runners using a hash-based distribution script (source). This got the full test suite to complete in under 10 minutes end-to-end.
Interestingly, CI also found an Embedded Dolt bug that was difficult to reproduce out in the wild!
Certain access patterns would fail to load a database and result in a nil pointer panic. Thanks to these tests, I got the fix into Dolt and its embedded driver, then upgraded the dependency in Beads.
After the create command, I worked through the remaining CRUD methods one at a time. Once I had confidence in the workflow, I started shipping whole groups of commands at once. The slowest part was writing (vibing) the integration tests, not the command migrations themselves.
Conclusion#
Today, Embedded Dolt is the default backend in Beads. You can see this change in behavior when running bd init from before I did this work.
➜ example git:(heads/v0.62.0) bd init
Note: origin has an existing beads database (refs/dolt/data).
Run 'bd bootstrap' instead to clone it.
Continuing with fresh database initialization.
Repository ID: b6599b84
Clone ID: d6e2a060ef90c83d
Contributing to someone else's repo? [y/N]: n
Hooks installed to: .beads/hooks/
✓ Created AGENTS.md with agent instructions
⚠ Git upstream not configured
For sync workflows, set your upstream with:
git remote add upstream <repo-url>
✓ bd initialized successfully!
Backend: dolt
Mode: server
Server: root@127.0.0.1:43127
⚠ Server host defaulted to 127.0.0.1.
If your Dolt server is remote, set BEADS_DOLT_SERVER_HOST or pass --server-host.
Database: example
Issue prefix: example
Issues will be named: example-<hash> (e.g., example-a3f2dd)
Run bd quickstart to get started.
To after.
➜ example git:(heads/v0.63.3) bd init
Note: origin has an existing beads database (refs/dolt/data).
Run 'bd bootstrap' instead to clone it.
Continuing with fresh database initialization.
✓ Configured Dolt remote: origin → git+https://github.com/gastownhall/beads.git
Repository ID: b6599b84
Clone ID: d6e2a060ef90c83d
▶ Already configured as: maintainer
Change role? [y/N]: N
Hooks installed to: .beads/hooks/
✓ Created AGENTS.md with agent instructions
⚠ Git upstream not configured
For sync workflows, set your upstream with:
git remote add upstream <repo-url>
✓ bd initialized successfully!
Backend: dolt
Mode: embedded
Database: example
Issue prefix: example
Issues will be named: example-<hash> (e.g., example-a3f2dd)
Run bd quickstart to get started.
If you want to use Beads with a Dolt server instead of the Beads Classic mode, you simply use the --server flag when running bd init now.
If you’re an existing Beads user with a Dolt server, you can migrate to Beads Classic using the bd backup command to back up your database to a local directory, then bd init to initialize in embedded mode, and bd restore to restore from your backup.
All told, this was about 120 commits over a month, implementing 86+ storage methods, extracting a shared issueops library, adding per-command integration tests sharded across 20 CI runners, and finding two real bugs in Dolt along the way.
The biggest takeaway for me was about how to drive a coding agent through a large, unfamiliar, vibe-coded codebase. The agent was indispensable for the volume of boilerplate and test code, but every architectural decision (the interface extraction, the shared library, the command-by-command migration strategy) had to come from a person with opinions. At least for the time being.