Give every AI coding thread its own environment that behaves like git for everything: code, dependencies and the running database.
Fern runs behind T3 Code. Each thread gets a sandboxed box with its own copy of the repository and its services (such as Postgres or a Vite dev server). Every agent turn is snapshotted, so reverting a thread in T3 brings back the files and the database as they were. Idle boxes sleep and wake on the next message, or when someone opens the thread's preview. Claude runs as the real claude CLI on your own subscription: no API keys.
What a thread gets:
- Undo for everything. A btrfs snapshot per turn (12 ms); a revert puts back files and Postgres together (about 0.1 s).
- A head start. A new thread starts from the repository's golden snapshot in 35 ms, with its pinned tools (Node, pnpm, … through mise),
node_modulesand its initialised database. - Web development. Dev servers Claude starts keep running; they hear Claude's edits;
http://<thread>.localhost:30780/is a preview that wakes the box and reloads by itself (edit to page: 0.4 s). - Sleep when idle. 30 s after a turn, Claude is stopped and both boxes sleep: no CPU until the next message.
flowchart LR
app["T3 app<br/>Mac or phone"] -->|pairing link| t3
subgraph server["One Linux server"]
t3["T3 server<br/>fern-t3.service"]
subgraph daemon["fern-daemon.service"]
api["API<br/>unix socket"] --> actors["actors<br/>one per environment"]
actors --> store["store<br/>btrfs"]
actors --> runsc["box driver<br/>runsc"]
mcp["MCP Bash"]
end
t3 -->|"run Claude, checkpoint,<br/>revert"| api
runsc --> agent["agent box<br/>Claude"]
runsc --> box["environment box<br/>fern-init, Postgres, app"]
agent -->|Bash tool| mcp
mcp -->|exec| box
store --- pool[("btrfs pool<br/>workspaces, snapshots")]
end
- The daemon (
fern-daemon) owns the environments. Each one has an actor, a goroutine that makes its changes one at a time, counts the commands running in its boxes and puts them to sleep when idle. - The store keeps each workspace as a btrfs subvolume. A snapshot is a btrfs snapshot (milliseconds, no copy), and a revert swaps the workspace for a copy of one in a single atomic rename. The disk is the only record: an environment is its folder with a small record file in it, and there is no database beside them.
- Boxes are gVisor sandboxes the daemon starts itself with
runsc, each with its own network namespace, an nftables firewall (internet only) and cgroup limits. Claude runs in an agent box of its own; its shell commands go through the daemon to the environment box, where the services are. The environment box never sees Claude's login. - Golden snapshots hold a repository's built setup (its pinned tools, installed dependencies, an initialised database), so a new thread starts from one instead of running setup again.
- The front door (
:30780) serves each box's ports as previews by host name, waking an asleep box; a request in progress, such as a live reload's WebSocket, keeps it awake. A fanotify watch on the host passes Claude's edits on to the dev servers in the environment's box, which gVisor would otherwise hide from them.
A turn, end to end:
sequenceDiagram
participant T3 as T3 server
participant D as fern-daemon
participant A as agent box (Claude)
participant B as environment box
T3->>D: run Claude in the thread's worktree
D->>A: wake the box, start Claude
A->>D: Bash: run the tests
D->>B: wake on first use, run the command
B-->>D: output
D-->>A: output
T3->>D: checkpoint the turn
D->>D: btrfs snapshot of the workspace
Note over D,B: nothing to do for 30 s: Claude is stopped, both boxes sleep
T3->>D: revert to an earlier turn
D->>D: stop the boxes, swap the workspace for the snapshot
Note over T3,B: the next message wakes the boxes and resumes Claude's session
On a Hetzner CAX21 VPS (4 ARM cores, 8 GB), gVisor, medians:
| time | CPU | |
|---|---|---|
| snapshot a turn | 12 ms | 20 ms |
| revert (files and database) | 86 ms | 140 ms |
| wake a box | 220 ms | 330 ms |
| put a box to sleep | 71 ms | 130 ms |
| run a command in an awake box | 19 ms | |
| new thread from a golden snapshot | 35 ms | |
| three forks of a running thread, Postgres included | 77 ms | |
| Claude's edit to a reloaded preview | 416 ms | |
| a whole short agent turn | 7.3 s |
Memory: 16 MiB for an awake box with Postgres idle, 10.8 MiB for the daemon. An idle box sleeps 30 s after a turn and then uses no CPU. An asleep thread costs about 75 KiB of disk and no memory: 585 of them added 0.9 MiB to the daemon, and a wake among them still takes 231 ms.
Against the first design (agent-sandbox pods on Kubernetes): 8.7× less CPU per turn, revert 9.6× faster, 20 boxes woken at once 25 s → 1.2 s.
A heavy dev server's first page: TanStack Start renders it in about 10 s in a box. Fern renders it ahead when Claude's turn wakes the box, so the page comes in 0.11 s; a browser that wakes an asleep thread itself still waits 16.6 s (4.6 s under runc: gVisor without KVM costs about twice the CPU).
How each number was measured: lab/RESULTS.md.
- Crash-safe by construction. Every multi-step change leaves the disk showing how far it got (a marker file, a copy beside the workspace, a folder without its record), and
Reconcilefinishes or clears it on start. Crash tests stop each change at every one of its steps, on a real btrfs filesystem in CI. - Actors, tested with a fake clock. One goroutine per environment, talked to through its mailbox. Idle sleeps, pre-wakes and concurrent changes are tested in
testing/synctestbubbles, withgoleakchecking for leaked goroutines. - Performance is gated.
lab/perf.shfails a change that makes any of its 288 metrics worse than the baseline (time, CPU, memory, disk and network).lab/ab.shruns two versions in alternation on the same machine and gives each metric's change with a 95% interval and a p-value.lab/profile.shtraces an operation with OpenTelemetry and draws flamegraphs of the whole node. - Sandboxing in depth. gVisor, no capabilities, a read-only root, the repository's git folder mounted read-only, a firewall that allows only the internet, and the login kept out of every box that runs the repository's code. Threat model:
docs/briefs/claude-outside-the-box.md; reporting:SECURITY.md. - Checked on every pull request. Unit, btrfs and fuzz tests; golangci-lint, shellcheck and hadolint; govulncheck on the Go and Trivy on the images; generated code and
go.modup to date. Actions are pinned to commits, and OpenSSF Scorecard grades the repository weekly.
You need a Linux server for Fern alone (Ubuntu 26.04 or Debian 13, arm64 or amd64, 4 GB or more; others may work) that you reach as root with an SSH key, a Claude subscription and the T3 app. From your Mac:
curl -fsSL https://raw.githubusercontent.com/nebler/fern/main/install.sh | bash -s -- root@1.2.3.4It installs Fern and T3's server on the server from the released images (a few minutes the first time, about a minute for an update), asks once for your Claude login there (claude setup-token: open the link, paste back the code, then the token), keeps an SSH tunnel to the server running in the background and copies a pairing link. In the T3 app: Settings → Connections, paste the link. Then open the sample project and start a thread: ask Claude to drop the email column, then revert.
Run the same command again to update, or for a new pairing link. When something doesn't work, bash install.sh doctor root@1.2.3.4 checks the server and the tunnel and says what to do. What to do for each failure, how to roll back an update, and what to set up so you hear of problems: docs/operations.md. The server gets a firewall that lets in only SSH (FERN_FIREWALL=off keeps yours): T3's port is never public, and the tunnel is how your Mac reaches it and the previews (http://<thread>.localhost:30780/).
Private repositories, push and pull requests: T3 pushes and opens pull requests from its server, with tokens from a GitHub App of your own: each lasts an hour and is for one repository, and none ever enters a box. Once:
- Create the App at github.com/settings/apps/new: any name and homepage; under Webhook, untick Active; under Repository permissions, Contents and Pull requests: Read and write (and for deploys to Cloudflare from a thread, Workflows and Secrets: Read and write too). Create it, note its App ID, and under Private keys, generate one (a
.pemdownloads). - Install it (Install App, in its settings) on the repositories Fern may use.
- Run the install again with it:
FERN_GITHUB_APP_ID=123456 FERN_GITHUB_APP_KEY=~/Downloads/your-app.pem bash install.sh root@1.2.3.4(or throughcurl … | FERN_GITHUB_APP_ID=… FERN_GITHUB_APP_KEY=… bash -s -- root@1.2.3.4).
Cloudflare: a repository that deploys to Cloudflare (wrangler, or Astro's, Vite's or OpenNext's Cloudflare adapter) runs in its box with D1, R2 and KV emulated locally, and their data is reverted with each turn. Claude gets Cloudflare's own skills (cloudflare/skills) and Fern's fern-deploy there. To deploy from a thread, give the server a Cloudflare API token once: run the install again with FERN_CLOUDFLARE_TOKEN_FILE=~/cloudflare-token (create it from this pre-filled form; in the form, also add User → User Details → Read, which Cloudflare's MCP server needs to accept the token), and give your GitHub App Workflows and Secrets. Then ask Claude in a thread to put the site online: it adds a GitHub Actions workflow and has Fern set the repository's secret (Claude never sees the token), and every push deploys, main live and other branches as preview URLs. With the token, Claude also gets Cloudflare's MCP server, in its own box only, to create D1 databases and R2 buckets or read a deploy's logs. Delete /etc/fern/cloudflare.env on the server to stop.
Claude's skills: Claude gets skill packs, each a set of skills baked into the box image (pinned) that it uses on its own:
| Pack | What Claude gets | On |
|---|---|---|
cloudflare |
Cloudflare's skills and Fern's fern-deploy |
in repositories that deploy to Cloudflare |
safety |
Fern's site-safety (keys, visitors' input, uploads, bots, data) and Cloudflare's turnstile-spin; /security-review before the first deploy |
by default |
beginner |
plain words, the preview link after every visible change, errors fixed rather than shown | opt-in |
design |
Emil Kowalski's design skills: emil-design-eng, animate, mobile-native |
opt-in |
engineering |
Dillon Mulroy's coding-standards and anti-slop linter, Matt Pocock's codebase-design and diagnosing-bugs | opt-in |
Choose a server's once: FERN_SKILLS="beginner design engineering safety" with the install (or install.sh skills "…" on the server); every thread on the server gets them from its next wake. An Astro site's Claude also gets Astro's docs server, so it reads current docs.
Claude's pause: Fern stops a thread's Claude after 30 seconds with nothing to do, so its box can sleep, and starts it again at the next message, which then takes about 3 seconds longer. To keep it for the messages that follow, at about 160 MiB a thread meanwhile, give T3's service a longer one on the server: Environment=FERN_HIBERNATE=5m under [Service] in /etc/systemd/system/fern-t3.service.d/hibernate.conf, then systemctl daemon-reload && systemctl restart fern-t3.
Backups: hourly to an S3 bucket (Hetzner Object Storage, R2, any S3), encrypted before they leave the server with a key that stays on your Mac (~/.config/fern/backup-key; keep a copy). Each thread's workspace goes as a btrfs send stream, incremental after the first, with each thread's record, T3's data and the projects' repositories. Create a bucket and an access key, then run the install again with FERN_BACKUP_ENDPOINT=https://fsn1.your-objectstorage.com FERN_BACKUP_REGION=fsn1 FERN_BACKUP_BUCKET=… FERN_BACKUP_ACCESS_KEY=… FERN_BACKUP_SECRET_KEY=…. To bring a lost server's threads onto a new one, install the new one with the same settings and FERN_RESTORE_FROM=<old server's hostname>.
Working on Fern itself: lab/setup.sh builds everything from your working tree, in a local lab (a systemd Linux node in Docker) or on a VPS with FERN_HOST=1.2.3.4; it needs Docker for the local lab and bash 5 (brew install bash). After a change, lab/dev.sh runs the unit tests, builds and runs the quick integration checks. AGENTS.md lists every command.
cmd/fern-daemon |
the daemon: wiring only |
cmd/fern-init |
a box's main process: runs the services, serves exec |
cmd/fern |
the CLI |
internal/actor |
one actor per environment, idle sleep, golden builds |
internal/store |
the btrfs pool, snapshots, retention, Reconcile |
internal/runsc |
boxes as gVisor sandboxes: bundle, network, firewall |
internal/boxexec |
the exec protocol over WebSocket |
internal/hibernate |
stops an idle Claude and resumes it unseen |
internal/setup |
the setup contract and the golden key, pinned tools included |
internal/preview |
the front door: previews by host name, waking boxes |
internal/watch |
passes Claude's edits on to the dev servers |
internal/api |
the daemon's HTTP API on a unix socket, and its client |
lab |
the lab, integration checks, perf gate, A/B and profiler |
docs |
architecture, design, roadmap and decision briefs |
Built and measured on a VPS: environments, snapshots per turn, revert, golden snapshots, idle sleep and wake, pre-wake, agent boxes, the T3 integration (T3 patches in images/t3), web development in a thread (dev servers, previews and pinned tools), your own repositories without Fern files, GitHub access through your GitHub App, fern doctor, supervision of crashing boxes, encrypted backups, rendering the first page ahead, forks (Claude's env_fork, env_exec, env_delete) and a one-command install from released images.
Next (roadmap): Codex next to Claude.
Fern v1, a control plane for coding-agent jobs in disposable containers, is at tag v1-final.