Postgres Is the Filesystem: Persistent Bash Across Replicas
A filesystem agents can share across replicas
AI agents do not need another Linux VM per turn. They need a tree. They ingest files into it, grep and transform them, write notes and scratch-pad analysis, and come back later on the same sandbox id, maybe from a different machine. That makes it a filesystem problem. The tree has to be durable, reachable over the network, and calm when traffic spikes and replicas multiply.
sql-fs is our answer. It runs just-bash on a Postgres-backed
IFileSystem with an adjacency-list schema and content-addressed blobs.
The API layer is stateless, so any replica can serve any sandbox.
At the start of every bash.exec, the replica compares vfs:ver with the last version it saw and calls reload() if they differ.
That check gives agents strong consistency. They always see the latest committed tree, even when a different replica wrote it.
Postgres is the source of truth. Redis coordinates writers, warms cold paths, and lets read-only work fan out.
You scale replicas on concurrent bash volume (we run on Azure Container Apps) instead of pinning agents to sticky containers or replaying setup on every cold start.
Standing on just-bash
We did not build a bash interpreter.
just-bash interprets bash scripts in Node against a pluggable
IFileSystem, with no containers and no host exec(). sql-fs is a backend for that interface.
Every readFile and mkdir becomes SQL, wrapped in caches and locks so it can scale horizontally without lying to agents.
Postgres as IFileSystem
Bash still sees the same IFileSystem interface, with readFile, writeFile, stat,
readdir, mkdir, mv, and the rest. sql-fs implements it
and persists everything through SQL.
We split the design into two layers. sql-fs is the object bash calls. It owns path normalization,
the in-process pathCache and contentCache, script transactions, read-only scopes, and the
"patch cache after commit" bookkeeping. SqlDialect is the SQL side (Postgres today). It owns
recursive CTEs, inode/dirent writes, blob upserts, RLS via SET LOCAL app.sandbox_id, and advisory locks inside transactions.
Swap the dialect and bash still talks to the same IFileSystem. The HTTP layer never learns which database backs the sandbox.
just-bash sees IFileSystem. sql-fs adds caches and transactions; SqlDialect speaks SQL; Postgres stores the tree.
The adjacency-list schema
The naive mapping is one row per file with the full path as the primary key. Simple, until you
mv a directory with thousands of children and rewrite every path string. Nobody wants that.
An inode is a node (file, directory, or symlink) with metadata but no stored path.
A dirent is a named edge: this directory has a child called foo pointing at that inode.
/home/user/project/main.py is not a column anywhere. You reconstruct it by walking dirents from the sandbox root.
mv /project/src /project/lib is one dirent update; inodes and blob bytes stay put.
File bytes live in blobs, keyed by SHA-256. Two files with identical content share one row.
An agent that copies the same config twelve times pays for the storage once. Deletes drop inode references, and orphaned blobs get garbage-collected later.
How sql-fs makes bash believe it is a normal filesystem
On sandbox open, createPostgresSandboxFs inserts a sandboxes row, creates a root inode plus the usual
/home, /tmp, /bin, and /home/user, then calls fs.ready().
That runs one recursive CTE (loadAllPaths) and fills pathCache, a Map from absolute path to inode metadata.
From then on, stat, exists, and readdir are map lookups in RAM with no SQL round-trip.
There is a catch baked into just-bash: getAllPaths() must be synchronous. You cannot
await Postgres inside it. So a SQL-backed IFileSystem has to front-load the tree into memory.
That is not garnish. The plugin contract requires it. File bytes load lazily. readFile tries
contentCache, then Redis blob cache, then Postgres.
Writes go the other way. sql-fs commits in Postgres first (inside #withTx, with RLS and
pg_advisory_xact_lock on the sandbox), then updates pathCache / contentCache and marks the FS dirty
so the session can publish vfs:ver for other replicas. Bash still thinks it called writeFile on a plain filesystem.
Under the hood it was inode rows, a dirent edge, and maybe a new blob row. The sections below cover what happens when more than one replica shares that sandbox.
A distributed filesystem, not a distributed bash
sql-fs runs as a pool of stateless HTTP replicas that scale on concurrent bash volume.
Each replica keeps a warm Session per active sandbox, which is a Bash instance wired to a sql-fs
with in-process caches. Postgres holds the tree and Redis coordinates replicas.
The rule is simple. Postgres is the truth, and everything else is a cache or a lock.
Per sandbox, agents get strong consistency. Mutating execs form one serial history, and every exec sees the latest committed tree when it starts.
The guarantee holds at exec boundaries. We do not promise POSIX linearizability for every syscall inside one long-lived shell session.
Say replica A runs fifty greps while replica B appends to the same log directory. Now you need answers.
Who may run bash at the same time? How do replicas coordinate writers? How do we make sure no agent turn answers from RAM that Postgres has already superseded?
Preferably without putting Raft on every stat().
Example: append a line to a log file on sandbox sb-7a2f
Ready
POST /sandboxes/sb-7a2f/exec
{ "script": "echo run-2 >> /workspace/runs.log" }
run-1
- POST arrives
- Redis exec lock
- Version check
- Exclusive RW
- stat → L1
- writeFile
- Patch L1 + L2
- COMMIT
- INCR ver
- Release
Press Play, one step at a time. Stop pauses; Resume continues from this step.
SessionManager.withSession, then cross-replica exclusive lock → version check → local exclusive lock → bash → Postgres → publish version. Read path uses a shared cross-replica RW lock and shared session.lock instead.Three caches, one write path
L1 (in-process, one per sandbox session) keeps hot syscalls off the network.
sql-fs loads the full path tree into pathCache once, via a recursive CTE or a Redis path snapshot on cold start when the embedded version matches vfs:ver.
After that, stat, readdir, and exists never touch SQL.
File bytes live in a 50 MB LRU contentCache keyed by inode, filled on first readFile.
Given the synchronous path-tree contract above, L1 is not optional. It is how a SQL backend plugs in at all.
L2 (Redis, shared) does three jobs. Blob cache stores immutable sha256 → bytes so repeat reads skip Postgres.
The version key vfs:ver:{sandboxId} tells replicas when their cache is stale. After a dirty exec, we INCR it while still holding the write lock.
Optional path snapshots write the whole L1 tree to Redis with the version stamped inside, so cold replicas can rebuild pathCache from one GET instead of a full CTE walk.
L3 (Postgres) is the adjacency list: inodes, dirents, content-addressed blobs, RLS scoped by sandbox_id.
On a write, we always commit here first inside #withTx, then patch L1, then send the L2 blob write without waiting for it.
Reads try L1 → L2 blob → L3. If you deleted every cache tonight, correctness would survive. Agents would just hate you, briefly.
RAM may lag Postgres between execs on a warm replica. That is fine, because nothing reads L1 until someone starts a turn.
At every exec entry, ensureFreshCache compares session.lastSeenVersion to Redis;
on mismatch, reload() clears L1 and rebuilds from L2 snapshot or Postgres before the first syscall.
That version gate gives us strong consistency across replicas without pub/sub on every inode. Warm L1 on an idle replica never reaches bash until it passes a version check.
The Redis exec lock still guarantees only one writer at a time, so the integer checked at boundaries is enough.
Ready
- Overview
- Distributed lock
- Version gate
- Exclusive RW
- L3 commit
- L1 patch
Choose Write or Read, then Play, one arrow at a time. Stop → Resume continues from this step.
Locks, RW grants, and script transactions
Locks answer who may run bash at the same time. The script transaction answers whether a single script's writes are all-or-nothing. Related problems. Not the same problem.
Redis exec lock (SET NX PX, heartbeat, Lua release) serializes mutating execs for a sandbox across every replica.
It is held for the whole withSession callback (version check, bash, publish), not per syscall.
If Redis is down we return 503. We do not write without it.
pg_advisory_xact_lock is the backstop inside each write transaction.
If a Node GC pause outlasts the Redis lease, two replicas could overlap. We hit that in load tests, which is why the advisory lock became non-negotiable.
The lock still serializes inode/dirent writes at the database.
We use the transaction-scoped variant so it survives PgBouncer transaction pooling. Session-scoped advisory locks break there without raising an error.
Neither the Redis exclusive path nor the advisory lock helps you run ten greps in parallel on one replica.
session.lock is a hand-rolled async RW lock: exclusive for default exec, shared for read_only.
Writers get priority. A queued writer blocks new readers so exploration cannot starve mutation.
When a writer finishes and readers are waiting, the lock wakes all of them at once.
On the write path, inside those locks, every bash.exec wraps its filesystem mutations in a script transaction.
The Postgres transaction opens on the script's first write. It commits on success. On failure it rolls back and reload()s L1.
A script that creates five files and dies on the sixth leaves no orphaned inodes for the next agent turn. That atomicity is the whole reason for the per-script transaction. The distributed lock solves a different problem.
Read-only exec skips script transactions, because concurrent readers would share one transaction handle.
read_only=True is the parallel read path through the same stack. Across replicas, a shared Redis RW lock
lets several replicas serve read-only execs at once, while a writer holds an exclusive flag. On one replica,
session.lock in shared mode runs many greps concurrently. beginReadOnlyScope() makes every mutating
sql-fs syscall throw EREADONLY before it reaches SQL. AsyncLocalStorage tracks violations per caller,
so one bad shell redirection does not poison its siblings. exec_batch(..., read_only=True) sends up to 16 scripts in one
HTTP round-trip and runs them under a single shared grant.
Shared-lock parallel read vs one-at-a-time exclusive exec.
Strong consistency across replicas
This is a distributed-system pitfall, and agents should never hit it.
Replica A finishes a mutating exec. It commits to Postgres, patches its own pathCache,
and ends with INCR vfs:ver:{sandboxId}, as in the walkthrough above.
Replica B was idle. It still holds the old tree in RAM from the last time it served this sandbox.
The load balancer sends the next agent turn to B. Without a guard, B would answer from stale L1 even though Postgres already moved on. Oops-shaped.
Every exec runs ensureFreshCache first. If vfs:ver moved, reload() rebuilds L1 from Postgres (or a version-matched path snapshot) before bash runs.
B starts from the latest committed state instead of the RAM left over from its last turn.
Mutating execs are serial across the cluster too. There is at most one writer per sandbox (the Redis exec lock, with pg_advisory_xact_lock as backstop), so committed history has no lost updates or torn trees.
We do not pub/sub every inode change. The version integer checked at exec boundaries is enough.
trigger: write finished on replica A → INCR vfs:ver later: LB routes next exec to replica B on entry: GET vfs:ver → if ≠ lastSeen → sql-fs.reload() → then bash.exec
The interactive below walks through two cases. The first is that cross-replica stale-cache recovery. The second mixes
read_only and write execs on the same or different replicas. Press Stop to freeze on any step.
1 · Warm state
- Warm state
- Write on A
- B still stale
- Without version gate
- Exec on B
- reload()
- Strong consistency
Both replicas served sandbox X earlier. pathCache loaded; lastSeenVersion = 1.
vfs:ver gate) or mixed read/write races (same replica RW lock + version check). Click any step to jump.A lightweight alternative to full sandboxes
Container sandboxes are the right tool when an agent needs a real OS: npm install, git clone,
outbound network, arbitrary binaries. Most agent turns are not that. They are file work: ingest a tree, grep and
awk across it, leave notes and analysis on disk, come back later on the same sandbox id.
sql-fs is a thin layer on Postgres and just-bash. There is no micro-VM per sandbox and no kernel to keep warm,
and agents still get the bash they already know.
You still get what agents need for file-heavy sandboxes:
create/list/delete sandboxes, bulk fs_ingest / fs_export, sync and streaming exec,
read_only and exec_batch for parallel exploration, optional Python/JS runtimes inside just-bash,
MCP tools, and the distributed semantics earlier in this post (locks, vfs:ver, script transactions).
What you do not get is a full Linux VM. That is the trade, on purpose.
Production latency
On a warm sandbox (133 ts files, Azure Container Apps Australia East), typical agent scripts take 40 to 180 ms of wall-clock time. Latency tracks the work a script does. There is no fixed container-exec floor.
3 lifecycle runs + 5 measured exec runs per case (1 warmup discarded). Reproduce with the benchmark script in the sql-fs repo.
133 files ingested in one request (~730 ms); create/delete under ~160 ms.
echo).Two layers to scale, not one VM per sandbox
The first is the bash/API layer. It is stateless HTTP, and replicas scale with the number of execs in flight. The repo includes the Azure Container Apps template we deploy, and you can adapt it. A session stays warm in RAM on whichever replica served it last, but any replica can pick up a sandbox after a version check. You scale for bash concurrency, not one container per sandbox id.
The second is Postgres, which holds the tree, the blobs, and the coherence metadata. Scaling it means scaling database compute and I/O with bigger instances, poolers, and read replicas for cold path-tree loads and cache misses (writes still go to the primary). Content-addressed blobs dedupe bytes; sandboxes are rows you can partition by tenant. Heavy load may mean more API replicas and a larger Postgres. Idle sandboxes do not need warm VMs.
Cost shape
Hosted sandboxes typically bill vCPU and memory per second for as long as the environment exists, idle or busy. Mintlify's ChromaFs write-up walks through the same economics for doc assistants at high conversation volume. The meter runs on reserved compute, whatever number of greps you needed. sql-fs flips the model. Sandboxes are durable rows and blobs in your database. You pay for API time while requests run and for storage/IO as the tree grows. Nobody pays to keep a VM open between agent turns. For a fleet of file-heavy agents, that difference in unit cost matters more than shaving milliseconds off a single exec.
When you need full isolation and package managers, use a container sandbox. When the job is durable file manipulation at scale, Postgres-backed sql-fs is the lighter default.
MCP and operator skills
Agents should not have to reverse-engineer OpenAPI to grep a tree. sql-fs offers the same
operations over MCP, with short tool names that keep the context small:
sandbox_create, sandbox_list, sandbox_delete,
bash_exec, bash_exec_batch, fs_ingest, fs_export.
The tool descriptions spell out what bash can and cannot do, when to bundle read-compute-write into
one script, and that readOnly: true on bash_exec_batch fans out parallel
exploration in a single round-trip.
Point agents at your deployment using the same Bearer JWT as the REST API.
In the repo I ship a
plugin
(Claude, Codex, Cursor) with two operator skills: api (curl, auth bootstrap, ingest, exec patterns)
and py-sdk (Python client workflows). They are reference material for humans and for agents:
setup steps, endpoint shapes, error codes, and copy-paste examples so a session can go from zero to
"ingest this tree and grep it" without rereading the whole spec each time.