Sandbox with the Official E2B SDK
K8E speaks the E2B wire protocol natively, so the official e2b SDKs (Python and JavaScript, unmodified) work against a K8E cluster with a two-line configuration change. Migrating an existing E2B application to K8E is configuration, not code:
import { Sandbox } from 'e2b';
const sbx = await Sandbox.create({
apiKey: process.env.E2B_API_KEY, // must be e2b_<hex> โ SDK validateApiKey rejects a bare hex token
apiUrl: 'http://127.0.0.1:3676', // control plane (root-mounted)
sandboxUrl: 'http://127.0.0.1:3676/e2b/envd', // envd data plane
});
This is the E2B-compatible surface from KIP-18: the control plane REST API at the root, the envd Connect-RPC surface under /e2b/envd, and signed file URLs at /files โ plus lifecycle and timeout semantics that follow CubeSandbox (pause / resume / auto-pause / NEVER_TIMEOUT / fine-grained errors).
Prerequisites
A running K8E server with the embedded E2B surface enabled (it is on by default;
--disable-e2bturns it off). The embedded server listens on:3676(--e2b-listento override) and also fronts the sandbox gRPC gateway on:50051through the Cilium Gateway API in a cluster. This guide is verified against K8Ev1.37.0-20261022-rc2and newer.An API key:
k8e sandbox-apikey create my-agent # โ {"name":"my-agent","key":"849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9","e2b_key":"e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9","ttl_days":30,"expires_at":"..."}The embedded e2b server auto-loads the same
sandbox-apikeysSecret the gRPC gateway uses (reloaded every 30 s), sok8e sandbox-apikey createalone is enough for official SDK clients โ no separate--e2b-apikeyneeded. Pass the JSONe2b_keyfield (ore2b_+ the barekey) to the SDK asapiKey; the SDK'svalidateApiKeyrejects a bare hex token before any request. A static key still works:--e2b-apikey/K8E_E2B_APIKEY(fallbackK8E_SANDBOX_APIKEY).The official SDK in your language of choice โ pinned to 2.56.0, the version this guide's examples are written against (the 2.x line renamed a few methods; see SDK version notes):
pip install "e2b==2.56.0" # Python npm install e2b@2.56.0 # JavaScript / TypeScript
Which URL to use
| Surface | On the server host | From another machine (via the Cilium Gateway) |
|---|---|---|
E2B control plane (apiUrl) | http://127.0.0.1:3676 | http://<gateway-host>:80 |
envd data plane (sandboxUrl) | http://127.0.0.1:3676/e2b/envd | http://<gateway-host>:80/e2b/envd |
| sandbox CLI (gRPC, mTLS) | 127.0.0.1:50051 | <gateway-host>:50051 |
The Gateway fronts both HTTP surfaces on its :80 listener โ the e2b control plane and the envd data plane are two HTTPRoutes bound to the same listener, so one host:port serves both. The :50051 TCP listener is L4 passthrough with mTLS preserved โ that is the one the CLI and MCP clients dial.
HTTPS
Releases that include k8e PR #671 also route the e2b surface through the Gateway's :443 listener, so https://<gateway-host> works as well as http://<gateway-host>:80. On earlier releases the :443 listener has no route and answers 404 โ use :80 there.
The gateway presents the same private CA the sandbox gRPC gateway uses, so the client has to trust it. Put it in the client's OS trust store โ the E2B SDK's transport reads the system store and ignores the SSL_CERT_FILE / REQUESTS_CA_BUNDLE environment variables:
# on the server: export the CA
sudo cat /var/lib/k8e/server/tls/sandbox-ca.crt > sandbox-ca.crt
# on the client: install it into the system trust store
sudo cp sandbox-ca.crt /usr/local/share/ca-certificates/k8e-sandbox-ca.crt
sudo update-ca-certificates
Set it once with environment variables
E2B_API_URL and E2B_SANDBOX_URL are read by both official SDKs, so you can keep the code free of URLs:
export E2B_API_KEY="e2b_<64-hex key>"
export E2B_API_URL="http://<gateway-host>:80"
export E2B_SANDBOX_URL="http://<gateway-host>:80/e2b/envd"
E2B_SANDBOX_URLoverrides the port too โ the SDK treats it as the complete base URL for the sandbox, so it must include the/e2b/envdpath. Leave it unset and the SDK derives a per-sandbox hostname (<port>-<sandbox-id>.<domain>) that a K8E cluster does not serve.
The template argument is a runtime class, not an E2B template
K8E builds the sandbox itself, so the e2b API's template field selects a Kubernetes RuntimeClass, not a prebuilt E2B template image. The default install provides gvisor, kata, firecracker, crun and lunatic; base (or no template) means the default runtime.
sandbox = Sandbox.create(api_key=..., template="gvisor") # a runtime class
Anything else is a 404: template '<name>' not found โ including code-interpreter-v1, which is what the Code Interpreter SDK asks for by default (see Code Interpreter SDK).
Verify it works before you write any more code
python3 - <<'PY'
import os
from e2b import Sandbox
with Sandbox.create(
api_key=os.environ["E2B_API_KEY"],
api_url=os.environ["E2B_API_URL"],
sandbox_url=os.environ["E2B_SANDBOX_URL"],
) as sbx:
print(sbx.commands.run("python3 -c 'print(40 + 2)'").stdout)
PY
# โ 42
If that prints 42, the control plane, the envd data plane, the API key and the runtime are all working.
Level 1 โ Create a sandbox and run a command
Python (the context manager kills the sandbox on exit):
from e2b import Sandbox
with Sandbox.create(
api_key="e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9", # e2b_ prefix + hex-only key (SDK validateApiKey)
api_url="http://127.0.0.1:3676", # control plane
sandbox_url="http://127.0.0.1:3676/e2b/envd",
) as sbx:
result = sbx.commands.run("python3 -c 'print(40 + 2)'")
print(result.stdout) # "42"
print(result.exit_code) # 0
print(sbx.sandbox_id) # e.g. "e2b-9f3c..."
JavaScript:
import { Sandbox } from 'e2b';
const sbx = await Sandbox.create({
apiKey: 'e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9',
apiUrl: 'http://127.0.0.1:3676',
sandboxUrl: 'http://127.0.0.1:3676/e2b/envd',
});
const { stdout, exitCode } = await sbx.commands.run('echo hello from k8e');
console.log(stdout, exitCode);
await sbx.kill();
The command runs in an isolated sandbox pod (gVisor by default) under /workspace, with egress restricted to an allowlist (pypi.org, registry.npmjs.org, github.com, โฆ). A background run (background=True / background: true) is the same wire โ disconnect() never kills and you can connect(pid) later.
Level 2 โ Files: write, read, list, stat, rename, makeDir, remove
Sandbox file operations ride native in-pod syscalls (no shell), with paths resolving under /workspace (E2B's /home/user/... default).
SDK 2.x renamed
files.stat()tofiles.get_info()โ samesize/name/modified_timefields. See SDK version notes. The JS SDK spells itgetInfo(camelCase, like its other methods);get_infois the Python name and does not exist in JS.
Python:
from e2b import Sandbox
with Sandbox.create(api_key="e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9",
api_url="http://127.0.0.1:3676",
sandbox_url="http://127.0.0.1:3676/e2b/envd") as sbx:
sbx.files.write("/workspace/notes/hello.txt", "hello from k8e")
print(sbx.files.read("/workspace/notes/hello.txt"))
print(sbx.files.list("/workspace/notes")) # [FileInfo(name="hello.txt", ...)]
print(sbx.files.get_info("/workspace/notes/hello.txt").size) # was .stat() pre-2.x
sbx.files.make_dir("/workspace/notes/sub")
sbx.files.rename("/workspace/notes/hello.txt", "/workspace/notes/world.txt")
sbx.files.remove("/workspace/notes/world.txt")
JavaScript:
await sbx.files.write('/workspace/notes/hello.txt', 'hello from k8e');
console.log(await sbx.files.read('/workspace/notes/hello.txt'));
console.log(await sbx.files.list('/workspace/notes'));
console.log((await sbx.files.getInfo('/workspace/notes/hello.txt')).size); // was stat()
await sbx.files.makeDir('/workspace/notes/sub');
await sbx.files.rename('/workspace/notes/hello.txt', '/workspace/notes/world.txt');
await sbx.files.remove('/workspace/notes/world.txt');
Byte round-trips work (write accepts binary; read returns bytes), and signed-URL download/upload (sbx.files.download_url(path) / sbx.files.upload_url(path)) is supported for browser-style access.
Level 3 โ Lifecycle: timeout, pause, resume, kill
K8E follows CubeSandbox lifecycle semantics: deadlines are enforced by the embedded e2b server, and -1 means NEVER_TIMEOUT.
Python:
with Sandbox.create(api_key="e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9", timeout=120,
api_url="http://127.0.0.1:3676",
sandbox_url="http://127.0.0.1:3676/e2b/envd") as sbx:
sbx.set_timeout(-1) # NEVER_TIMEOUT โ clear the deadline
sbx.pause() # release the pod, keep the PVC + files
# SDK 2.x has no sbx.resume(): any later call (or Sandbox.connect) wakes
# the paused sandbox automatically. See the version notes below.
info = sbx.get_info()
print(info.state) # "running" | "paused"
# List sandboxes (v2.x returns a SandboxPaginator; pull a page with next_items())
from e2b import Sandbox
paginator = Sandbox.list(api_key="e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9",
api_url="http://127.0.0.1:3676")
for s in paginator.next_items():
print(s.sandbox_id, s.state)
pause()releases the pod (CPU/memory) but keeps the workspace PVC โ files survive a resume. Since the unified session model, every session is persistent and carries a PVC, so pause works on any session.- NEVER_TIMEOUT is Python-only.
sbx.set_timeout(-1)sends-1as seconds, and the server reads it as "no deadline". The JS SDK'ssetTimeout(timeoutMs)takes milliseconds and always converts withMath.ceil(ms / 1000), so-1becomes-0, serialises as0, and the server rejects it with400 invalid timeout. From JavaScript, set a real deadline (sbx.setTimeout(120000)) or leave the sandbox timeout alone. - Auto-pause at the deadline and auto-resume on the next request (connect / exec / file I/O) both work. In SDK 2.x this auto-resume is the resume path โ there is no
sbx.resume()call, and a plainSandbox.connect(sandbox_id, ...)brings a paused sandbox back. - A killed sandbox answers 404 โ the SDK's
kill() === falsecontract.
Level 4 โ Watch file changes (polling trio)
K8E implements the E2B watch polling trio (CreateWatcher / GetWatcherEvents / RemoveWatcher) via inotify in the sandbox daemon.
watch_dir() / watchDir() returns a watch handle, not an iterator. FilesystemEvent.name is the path of the changed entry relative to the watched directory (the SDK documents it as "Relative path to the filesystem object"), so a watcher on /workspace reports a.txt for a change to /workspace/a.txt.
Python:
from e2b import Sandbox
with Sandbox.create(api_key="e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9",
api_url="http://127.0.0.1:3676",
sandbox_url="http://127.0.0.1:3676/e2b/envd") as sbx:
watcher = sbx.files.watch_dir("/workspace") # polling trio underneath
sbx.files.write("/workspace/a.txt", "hi")
events = watcher.get_new_events() # NOT next(watcher)
for event in events:
print(event.name, event.type) # "a.txt" <FileEventType.CREATE>
watcher.stop()
JavaScript:
const events = [];
const handle = await sbx.files.watchDir('/workspace', (event) => {
events.push(`${event.name}:${event.type}`); // "a.txt:create"
});
await sbx.files.write('/workspace/a.txt', 'hi');
// give the sandbox a moment to deliver the inotify event
await new Promise((r) => setTimeout(r, 1000));
await handle.stop();
console.log(events);
next(watcher) does not work in SDK 2.x
watch_dir() returns a WatchHandle โ it defines stop() and get_new_events() and no __next__, so next(watcher) raises TypeError: 'WatchHandle' object is not an iterator. That is a property of the SDK, not of the backend: it fails the same way against E2B's own cloud. Use get_new_events() (Python) or the onEvent callback (JavaScript).
The event name needs a recent sandboxd
Sandboxd older than v1.37.0-20261022-rc2 mis-parsed the inotify record and reported the watched directory (/workspace/) instead of the changed entry. If your events carry a directory path rather than a file name, upgrade the sandbox image to v1.37.0-20261022-rc2 or newer.
The streaming
filesystem.Filesystem/WatchDirRPC is not implemented (no SDK uses it); the polling trio is what the official SDKs drive.
Level 5 โ Point your agent at K8E instead of a template
The most common migration: an AI agent already wired to E2B targets K8E with just the two URLs โ no code changes. For example, in an agent tool wrapper:
# agent/tools/sandbox.py โ unchanged E2B code, K8E backend
from e2b import Sandbox
def run_in_sandbox(code: str) -> str:
with Sandbox.create(api_key="e2b_849a5302e66f98d1d5064ef8501703574af4053b7bf9cf5337f9533326ce2bc9",
api_url="http://127.0.0.1:3676",
sandbox_url="http://127.0.0.1:3676/e2b/envd") as sbx:
r = sbx.commands.run(f"python3 -c {code!r}")
return r.stdout if r.exit_code == 0 else r.stderr
Compare with the harness-driven k8e-sandbox-cli and the /k8e-sandbox skill: the CLI route is agent-harness oriented (Claude Code / Codex / Pi drive it), while the E2B SDK route is for code that already speaks E2B โ pick whichever fits your stack. Both hit the same sandbox infrastructure.
Not supported: the Code Interpreter SDK
The e2b-code-interpreter / @e2b/code-interpreter packages are a different SDK from the e2b packages above, and K8E does not implement them. If you are porting from the official E2B cookbook, this is the first thing you will hit โ the cookbook's hello-world-python and hello-world-js both use the Code Interpreter SDK.
Two independent gaps:
- No
code-interpreter-v1template. The Code Interpreter SDK asks for that template by default; K8E resolvestemplateas a RuntimeClass, so the request fails with404: template 'code-interpreter-v1' not found. - No
/executeroute.run_code()speaks the Code Interpreter protocol: the SDK POSTs to{sandboxUrl}/executewith anE2b-Sandbox-Port: 49999header and expects newline-delimited JSON back ({"type":"stdout","text":"โฆ","timestamp":โฆ}), because a code-interpreter server is supposed to be listening on port 49999 inside the sandbox. The K8E sandbox image ships sandboxd (plus python3 / git / node), not a code interpreter, and neither sandboxd nor the K8E e2b server implements the route.
What to do instead โ the plain e2b SDK covers the same ground:
# from e2b import Sandbox # NOT e2b_code_interpreter
with Sandbox.create(api_key=..., template="gvisor") as sbx:
result = sbx.commands.run("python3 -c 'print(\"hello world\")'")
print(result.stdout) # "hello world\n"
print(sbx.files.list("/")) # [] on a fresh sandbox
The same example with the Code Interpreter SDK, for comparison, fails at Sandbox.create() with the template 404 โ and, once the template is fixed to gvisor, at run_code() with route POST /e2b/envd/execute not found.
The protocol is not the hard part
The /execute contract is plain NDJSON, and a minimal responder is enough to satisfy the official SDK: POST {code, language, context_id, env_vars}, then stream {"type":"stdout","text":โฆ} lines. A code-interpreter server is not required. Porting it is a three-part change (sandboxd /execute route, the K8E e2b server route that proxies it, and a code-interpreter-* template alias) โ see KIP-31.
Paths resolve under /workspace
Sandbox file paths are sandbox-relative, and the sandbox root is/workspace โ that is E2B's /home/user/โฆ default, and it is where the workspace PVC is mounted. So:
sbx.files.list("/") # the workspace root โ [] on a fresh sandbox
sbx.files.list("/workspace") # the same directory
sbx.files.list("/etc") # FileNotFoundException: path not found: /workspace/etc
A fresh sandbox has an empty workspace โ files.list("/") returning [] is correct, not a failure. Absolute paths outside /workspace are not visible to the SDK; reach them through commands.run instead.
What is (and is not) supported
Works out of the box: create โ connect โ getInfo โ list โ kill โ timeout (incl. -1 NEVER_TIMEOUT) โ pause โ resume โ auto-pause โ auto-resume, live commands.run streaming, commands.list, commands.connect, file write/read/list/stat/rename/makeDir/remove (incl. binary round-trips), signed-URL download/upload, watch via the polling trio, metadata.name idempotency.
Honest 501s (SDK methods throw with a machine-readable hint): PTY (pty.create, process.Process/Update, UpdatePTY), StreamInput, streaming WatchDir, metrics (returns []), and the templates registry (only runtime-class names are accepted as templateID). These are K8E-runtime gaps, not protocol gaps โ see KIP-18 for the full gap audit and implementation status.
Not implemented at all (the SDK call fails, not with a 501 but with a route/template error): the Code Interpreter SDK โ run_code(), create_code_context() and the code-interpreter-v1 template. Use the plain e2b SDK's commands.run instead.
SDK version notes
Examples in this guide are written against E2B SDK 2.56.0 (Python and JavaScript). The 2.x line renamed or moved a few methods compared to the 1.x examples you may see elsewhere, and K8E follows the SDK, so pin the version to avoid surprises:
pip install "e2b==2.56.0" # Python
npm install e2b@2.56.0 # JavaScript / TypeScript
| Pre-2.x (1.x) | 2.56.0 | Notes |
|---|---|---|
sbx.files.stat(path) | sbx.files.get_info(path) | Same size / name / modified_time fields |
sbx.pause() / sbx.resume() | sbx.pause() only | beta_resume() was removed; resume is implicit โ the next request (connect, exec, file I/O) wakes a paused sandbox |
| โ | sbx.files.exists(path) | New helper, works on K8E |
| โ | sbx.files.write_files([...]) | Batch write |
Two things to know about the 2.x resume model on K8E:
pause()keeps the workspace PVC, so files survive the wake.- To resume explicitly, call
Sandbox.connect(sandbox_id, api_key=..., api_url=..., sandbox_url=...)โ K8E's connect auto-resumes and answers201.
Binary uploads. files.write() carries non-UTF-8 bytes safely: K8E's upload path detects a binary body and transfers it as encoding="base64" (sandboxd's binary-safe chunk format), so the SDK's octet-stream/gzip/multipart uploads all round-trip byte-faithfully. Text payloads keep the direct path.
Large uploads. There is no per-file size limit beyond the sandbox's own disk; a single upload body is bounded at 512 MiB as a memory guard only. The SDK streams the body, so a ~few-hundred-MB write in one call is fine. Past that, stage the file with a shell append โ the SDK's own write() always overwrites and exposes no offset, so chunked writes of one file need the command path:
CHUNK = 32 * 1024 * 1024 # 32 MiB per request
with open("model.bin", "rb") as f:
first = True
while chunk := f.read(CHUNK):
if first:
sbx.commands.run(
f"base64 -d > /workspace/model.bin <<'EOF'\n"
f"{base64.b64encode(chunk).decode()}\nEOF")
first = False
else:
sbx.commands.run(
f"base64 -d >> /workspace/model.bin <<'EOF'\n"
f"{base64.b64encode(chunk).decode()}\nEOF")