Running Deep Agents in a Local OpenSandbox Backend
How to wire OpenSandbox into Deep Agents through BaseSandbox, bridge sync and async code, and run a data analysis agent safely in a local Docker sandbox.
This article is published in English.
Sources: Upgrade Your Deep Agent With a Local Open-Source Sandbox
Why agents need somewhere safe to run code
Chat assistants like ChatGPT, Claude, Gemini and Qwen accept uploads such as images, PDFs and spreadsheets. Many requests on those files, say describing a picture, summarizing a PDF or pulling a snippet out of a document, are within a language model's native abilities. Hand one a .csv and ask for a full statistical analysis with charts, though, and plain reasoning stops being enough. The job requires actual data manipulation, calculation and chart rendering.
An LLM can't do that math or drawing itself, but it can write a Python script that uses pandas, NumPy and Matplotlib to do it. After the script runs, the model's remaining job is to turn the results into a readable report. Agentic tools are what make executing those scripts possible.
Running AI-written code directly on your own machine is a bad idea, though. Unsupervised execution is a serious security and stability risk: it might change system configuration, delete critical files or expose personal data. Hence sandboxes, secure isolated environments where the agent can run its code without reaching the underlying operating system. This material focuses on sandboxes for the Deep Agents Python library.
What a sandbox is and why agents need one
Here, a sandbox means a contained environment, typically a container, in which code runs without any contact with the rest of the machine. Its filesystem and processes are its own, and nothing leads back to the host's files, credentials or network unless access is granted on purpose. Developers can treat that as a convenience, but for an autonomous agent it's nearly a requirement, and the reasons are practical:
- The agent chooses the code, not the user. Even a well-behaved agent will sometimes delete the wrong file or run a command nobody intended, and without isolation the damage hits the real machine.
- Prompt injection is real. If the agent reads untrusted content, such as a web page or a file from someone else, a cleverly worded hidden instruction can try to redirect what it does next. A sandbox confines the damage to a disposable container.
- Clean slates help. Each task is easier to reason about when it begins in a known, empty environment and doesn't inherit state from earlier runs.
- Parallel agents. With isolation, one agent's
pip installcan't collide with another's, and a crashed sandbox won't drag down the whole process. - Resource limits. An agent in a runaway loop, or one that loads a whole dataset into memory twice, should use up a throwaway container's resources and not your PC's.
That's why frameworks aimed at coding agents, Deep Agents included, treat the sandbox as a first-class concept. Deep Agents ships an abstraction called BaseSandbox, plus official integrations with a few cloud sandbox providers. Point the agent at one and it gets a real, isolated place to run code.
All of these providers are hosted in the cloud and charged per minute or by usage. That suits production, but it's more than needed for building, testing or demoing an agent locally.
OpenSandbox as a local option
OpenSandbox is a free, open-source sandbox runtime that runs on your own Docker. It offers the same core primitive as the paid options:
- Start an isolated container.
- Run commands in it.
- Read and write files there.
- Tear it down afterward.
Start its control-plane server locally, aim it at Docker, and a local endpoint appears that creates and manages sandboxes whenever asked. Nobody sells it as a hosted product. It's infrastructure you operate yourself, which fits local development, testing and demos like this one. What remains is connecting it to Deep Agents, which includes no OpenSandbox integration.
The BaseSandbox contract
BaseSandbox is an abstract class, and the integration comes down to four members.
class BaseSandbox(ABC):
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: ...
@property
def id(self) -> str: ...
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: ...
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: ...
executeruns a command in the sandbox and returns anExecuteResponse.idis a unique identifier for the sandbox backend.upload_filessends several files into the sandbox.download_filespulls several files out.
Once those four exist, Deep Agents builds ls, read_file, write_file, edit_file, glob and grep on its own, layered over execute(). Nothing more has to be coded for them. This is the complete integration surface, which means any sandbox capable of running a shell command and moving files in and out is eligible. The path looks easy, until real coding starts and the bugs appear.
How OpenSandbox works
OpenSandbox uses Docker containers to create and manage sandboxes. Its Python SDK can create and manage sandbox environments, run shell commands, handle files and monitor resources. Setup needs Docker installed and running, and then the server itself:
# Generate a starter config
uvx opensandbox-server init-config ~/.sandbox.toml --example docker
# Start the server
uvx opensandbox-server
After that, the SDK connects to the running server and exposes everything OpenSandbox can do.
The integration class
That's the skeleton to fill in, following the BaseSandbox abstraction. The methods go one at a time.
create()
class MinimalOpenSandboxBackend(BaseSandbox):
def __init__(self, sandbox: Sandbox, runner: AsyncRunner):
self._sandbox = sandbox
self._runner = runner
__init__ is plain. It holds two objects that everything else depends on: the live Sandbox connection to the server and the AsyncRunner that drives it. Neither is built by hand, since both come out of a factory method. The class also ends up with more methods than the four BaseSandbox requires, most notably a .create() class method. The reason is clearer from how a sandbox object actually comes into being.
The Sandbox connection and the background runner must both exist before __init__ can use them, and that's why create() is its own step. Here's the code.
from opensandbox import Sandbox
from opensandbox.config import ConnectionConfig
IMAGE = "opensandbox/code-interpreter:v1.1.0"
ENTRYPOINT = ["/opt/code-interpreter/code-interpreter.sh"]
@classmethod
def create(cls, api_key: str | None = None) -> "MinimalOpenSandboxBackend":
runner = AsyncRunner()
config = ConnectionConfig(domain="localhost:8080", api_key=api_key)
sandbox = runner.run(
Sandbox.create(IMAGE, entrypoint=ENTRYPOINT, connection_config=config, timeout=timedelta(minutes=30))
)
return cls(sandbox, runner)
Two details stand out. domain="localhost:8080" links this to the server started earlier with uvx opensandbox-server. timeout=timedelta(minutes=30) works as a safety net: forget to call kill() and the sandbox still tears itself down instead of lingering in the background. Also watch IMAGE and ENTRYPOINT, which must be compatible with the OpenSandbox version in use.
About the runner: Sandbox.create is a coroutine, yet BaseSandbox.create must hand back an ordinary object instead of something awaited. Until the details come up, treat runner.run(coro) as "wait for this async call to finish, then give me what it returned." Which explains why Sandbox.create(...) is passed into runner.run(...) instead of being awaited directly. How AsyncRunner works inside is covered a little later.
id
Creation is finished, so the remaining methods deal with a sandbox that's already running.
@property
def id(self) -> str:
return self._sandbox.id
It returns the id of the underlying sandbox. BaseSandbox demands it as a property, and it's convenient in logs.
execute()
from deepagents.backends.protocol import ExecuteResponse
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
execution = self._runner.run(self._sandbox.commands.run(command))
stdout = "\n".join(c.text for c in execution.logs.stdout)
stderr = "\n".join(c.text for c in execution.logs.stderr)
output = "\n".join(p for p in (stdout, stderr) if p)
return ExecuteResponse(output=output, exit_code=execution.exit_code or 0)
Deceptively simple, this one. OpenSandbox delivers command output as a list of chunks, one per line, and the newline has already been removed by then. The response must therefore be rebuilt properly for the agent to handle it later. execute() isn't only called directly, either. ls, glob and grep in Deep Agents all rest on it and read output line by line. Hand them a malformed response and they match nothing and quietly return empty results. ls yields [], no traceback blames the faulty join, and the sandbox seems empty when it isn't.
It matters more than it seems, because execute() isn't just for direct use. It's the foundation under ls, glob and grep inside Deep Agents, and all of them parse output a line at a time. A badly formatted response makes them match nothing and silently return empty results. ls then gives [] with no traceback pointing at the faulty join, and the sandbox looks empty when it isn't.
The rest is simpler: stdout and stderr are concatenated, skipping whichever is empty, and a missing exit code counts as success (0) instead of staying None.
upload_files()
from opensandbox.models import WriteEntry
from deepagents.backends.protocol import FileUploadResponse
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
entries = [WriteEntry(path=path, data=data, mode=644) for path, data in files]
try:
self._runner.run(self._sandbox.files.write_files(entries))
return [FileUploadResponse(path=p) for p, _ in files]
except Exception as exc:
return [FileUploadResponse(path=p, error=str(exc)) for p, _ in files]
Every (path, bytes) pair is wrapped in a WriteEntry with ordinary permissions (owner can read and write, others can only read), and the full batch is sent through one write_files() call.
download_files()
from deepagents.backends.protocol import FileDownloadResponse
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
results = []
for path in paths:
try:
content = self._runner.run(self._sandbox.files.read_bytes(path))
results.append(FileDownloadResponse(path=path, content=content))
except Exception as exc:
results.append(FileDownloadResponse(path=path, error=str(exc)))
return results
The detail that matters is read_bytes over read_file. The SDK offers both, but read_file assumes UTF-8 text. A sandbox running an LLM's code can't know whether the next file requested is a CSV or a freshly rendered chart, so read_bytes is the only safe choice for both.
kill()
def kill(self) -> None:
self._runner.run(self._sandbox.kill())
self._runner.shutdown()
Teardown does two important things. It kills the sandbox container, and it explicitly shuts down the runner's background thread. Skip the second step and the thread, along with the event loop inside it, just stays around after the sandbox is gone.
That's the entire class: two lifecycle methods (create, kill) wrapped around the four that BaseSandbox demands (id, execute, upload_files, download_files).
The sync/async bridge
One piece has been left unexplained: self._runner.run(...), the thing that makes every async SDK call look synchronous to BaseSandbox.
BaseSandbox is synchronous. execute(), upload_files() and download_files() are plain function calls with no await. The OpenSandbox SDK is async, though: Sandbox.create(), sandbox.commands.run() and sandbox.files.write_files() are all coroutines. AsyncRunner exists to close that gap.
The approach is short. One event loop is created, given to its own background thread, and kept alive as long as the sandbox exists. Everything else uses only run(), which submits a coroutine to that loop via run_coroutine_threadsafe, blocks the calling code until it completes and returns the outcome. To execute(), self._runner.run(some_coroutine) looks just like an ordinary function call.
A single long-lived loop, instead of a new one per call, also lets the connection act as one continuous session and not something torn down and rebuilt at every execute(). shutdown() handles cleanup by stopping the loop and ending the thread cleanly, so kill() simply calls it.
A data analysis agent in the sandbox
To test the integration, a Jupyter notebook starts by setting environment variables and constants, importing packages and, most importantly, creating a MinimalOpenSandboxBackend object. That requires an API key you create yourself and set in the ~/.sandbox.toml file.
import asyncio
import nest_asyncio
import threading
from datetime import timedelta
from pathlib import Path
from deepagents import create_deep_agent
from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse
from deepagents.backends.sandbox import BaseSandbox
from langchain.chat_models import init_chat_model
from opensandbox import Sandbox
from opensandbox.config import ConnectionConfig
from opensandbox.models import WriteEntry
# nest_asyncio for running async functions in Jupyter.
nest_asyncio.apply()
IMAGE = "opensandbox/code-interpreter:v1.1.0"
ENTRYPOINT = ["/opt/code-interpreter/code-interpreter.sh"]
backend = MinimalOpenSandboxBackend.create(api_key="SANDBOX_API_KEY")
print("Sandbox ready:", backend.id)
Next comes a chat model. Local models through Ollama would work too, but the model still has to handle agentic tasks. This run uses a Gemini Flash model.
llm = init_chat_model(
model="gemini-3.5-flash",
model_provider="google_genai",
api_key=os.environ["GOOGLE_API_KEY"],
max_tokens=14750,
max_retries=5,
)
Then the agent itself.
agent = create_deep_agent(
model=llm,
system_prompt=(
"You are a Python coding assistant with sandbox access. "
"You specialize in performing data analysis and data visualization with python,"
"you generate clear reports with good looking charts using seaborn."
),
backend=backend,
)
Everything built so far pays off in the backend=backend argument. From that single object, Deep Agents generates ls, read_file, write_file, glob and grep itself, using the custom execute() as the base.
Some real data comes next, loaded with upload_files() directly. The dataset is a fake one of 1,000 customers.
csv_bytes = Path("customers-1000.csv").read_bytes()
results = backend.upload_files([("/workspace/customers-1000.csv", csv_bytes)])
for r in results:
if r.error:
print(f"Upload failed for {r.path}: {r.error}")
else:
print(f"Uploaded {r.path}")
Then the agent gets the actual task.
result = agent.invoke({
"messages": "Perform a deep exploratory data analysis on the customers-1000.csv file "
"and summarize the findings in a markdown report with clear charts."
})
At this stage the agent works alone. With its tools and infrastructure, it can write pandas and seaborn code, run it in the sandbox through execute(), store charts on the sandbox filesystem and keep iterating until a complete markdown report exists.
One snag remains: part of the report stays inside the sandbox. Its markdown points at charts using sandbox-local paths, which don't exist on the host. Making it readable takes two steps, downloading the chart images with download_files() and rewriting the markdown image paths so they point to the local copies. A less brittle approach has the agent return structured output, for instance a report field and an explicit list of image paths, in place of combing free-form markdown for links. Post-processing is then just downloading every listed path and substituting it.
After that, here's what the sandbox produced from the raw CSV.
# Deep Exploratory Data Analysis: Customer Acquisition and Profiling
**Dataset:** `customers-1000.csv`
**Analysis Period:** Jan 2020 – May 2022
---
## 1. Executive Summary
This report presents a comprehensive exploratory data analysis (EDA) of a customer database containing 1,000 unique records. The analysis delves into geographical distributions, sign-up temporal trends, domain & technical alignments, and name demographics to uncover actionable insights for strategic growth.
### Key Takeaways
1. **Unprecedented Global Reach:** The customer base is extraordinarily decentralized, spanning **240 countries** across all **7 continents** (including Antarctica). No single country represents more than 1.2% of the customer base. Africa (24.7%) and Asia (22.6%) are the leading regions, followed by Europe (18.5%) and North America (16.1%).
2. **Stable Acquisition Trends:** Customer subscriptions are remarkably stable, averaging roughly **34-35 new customers per month** across 2020 and 2021. This consistency is maintained across all continents year-over-year, indicating a highly standardized, globally distributed customer acquisition channel.
3. **Mid-Week and Weekend Consistency:** Subscriptions are evenly spread across the days of the week, with a minor peak on Friday and Saturday, and a minor trough on Thursday.
4. **B2B / Synthetic Profile Characteristics:** The dataset shows zero domain overlap between customer email domains and company websites (0.0% exact match across 923 unique domains). Combined with the near 1-to-1 ratio of customers to companies, this suggests a highly B2B-centric profile (one representative per enterprise) or synthetically generated profiles with randomized fields.
5. **Standardized TLD Footprint:** The `.com` top-level domain (TLD) dominates both emails (61.2%) and corporate websites (58.8%). The remaining distribution is evenly split among `.org`, `.net`, `.biz`, and `.info`.
---
## 2. Dataset Structure & Data Integrity
The initial dataset contains **1,000 rows** and **12 columns**. An inspection of data integrity reveals excellent completeness:
- **Zero Missing Values:** Every column is 100% populated.
- **Zero Duplicates:** There are no duplicate rows, and the `Customer Id` column contains 1,000 unique identifiers.
- **Data Types:** All columns are stored as object/string types except for `Index` (integer).
### Data Preprocessing & Feature Engineering
To enable deep exploratory analysis, several features were engineered:
1. **Temporal Features:** `Subscription Date` was parsed as a datetime object, allowing the extraction of `Sub_Year`, `Sub_Month`, `Sub_Month_Name`, `Sub_Day_of_Week`, and `Sub_Year_Month` (period).
2. **Geographical Mapping:** Using the `pycountry` and `pycountry-convert` libraries, coupled with a manual fallback dictionary for territories, each of the 240 countries was successfully mapped to its respective **Continent**.
3. **Domain & Technical Profiles:** Email domains (`Email_Domain`), email TLDs (`Email_TLD`), and website TLDs (`Website_TLD`) were extracted to analyze the technical profiling of users.
---
## 3. Geographical Analysis
### Continent-Level Distribution
The geographic reach of this customer base is truly global. Rather than being concentrated in a single dominant market like North America or Europe, customers are spread across all continents:
| Continent | Customer Count | Percentage |
| :--- | :---: | :---: |
| **Africa** | 247 | 24.7% |
| **Asia** | 226 | 22.6% |
| **Europe** | 185 | 18.5% |
| **North America** | 161 | 16.1% |
| **Oceania** | 107 | 10.7% |
| **South America** | 54 | 5.4% |
| **Antarctica** | 20 | 2.0% |
#### Chart 1: Customer Distribution by Continent

### Country-Level Distribution (Top 15 Countries)
The country-level distribution exhibits a heavy tail, with the 1,000 customers distributed across **240 distinct nations**. This indicates that the average number of customers per country is only **4.17**.
The top countries by customer density are:
- **Liechtenstein:** 12 customers (1.2%)
- **Gabon:** 10 customers (1.0%)
- **China, Bangladesh, Reunion, Nigeria, Luxembourg:** 9 customers each (0.9%)
This extreme dispersion suggests a borderless, digital-first product that appeals universally across jurisdictions without localized geographic bias.
#### Chart 2: Top 15 Countries by Customer Count

---
## 4. Temporal Analysis (Subscription Trends)
... (Trimmed to keep blog (estimated read time short))
A cropped view of the rendered report:
From notebook to a PyPI package
A notebook demonstrates how the pieces fit, but a package that anyone can install with pip is more useful. Making deepagents_opensandbox_backend meant repairing everything that fails only once other people run it in setups the author never tried. The public surface stayed put, with the same BaseSandbox methods and the same create() and kill(). Changes happened underneath:
- One shared loop. Backends now share a single module-level
_BackgroundLoopand don't run separate threads each. - A security warning by default. The API is unauthenticated, so
create()now raises anInsecureSandboxWarningwhen noapi_keyis given, where before it connected silently. - Docker Desktop workaround. On Docker Desktop, connecting straight to a container port frequently times out. Setting
use_server_proxy=Truesends traffic through the server and sidesteps the problem. - Path validation.
upload_filesanddownload_filesreject relative paths up front, so the server can't quietly resolve them to the wrong directories. - Clearer errors. The client checks file info to tell "permission denied" from "file not found" and maps each to the expected error code, rather than passing along misleading raw errors.
- Timeouts that work. The
timeoutargument used to be ignored. It's now passed to the server to kill runaway commands instead of leaving them orphaned once the client gives up. - Hand-written async methods dropped. Real async versions such as
aexecutecaused silent, empty outputs inside LangGraph'sToolNode, so they were deleted, and the code now falls back to the normal sync methods running in a thread pool.
That gap separates a demo that works on one laptop from a package ready for anyone to pip install. A conformance test suite, run against the standard sandbox tests from Deep Agents itself, ships with it, and installation looks like this:
!pip install deepagents-opensandbox-backend
It's the same integration described above, with the sharp edges filed down.