AI coding agents can write files, install Python packages, run scripts, and create charts. But where should that code run?
Running arbitrary agent-generated code directly on a local laptop or production server can be risky. A sandbox gives an agent an isolated environment where it can use a filesystem and shell commands without directly accessing the host machine.
This article presents a small two-turn data science pipeline using:
- Daytona for a remote development sandbox
- Deep Agents for an AI agent with filesystem and shell access
- LangGraph for conversation memory
- NVIDIA NIM as the LLM provider
The objective is not only to train a model. The example demonstrates that:
- Python packages installed in the first turn remain available in the second turn.
- Files created in the first turn still exist in the second turn.
- The agent retains conversation context when the same
thread_idis reused.
What the example builds
The program makes two agent calls:
| Call | Agent task | Persistence proof |
|---|---|---|
| Call 1 | Installs Python packages, generates data, trains a model, and saves a chart | Creates data.csv, model.pkl, and chart.png
|
| Call 2 | Loads the saved model, predicts a class, and checks the chart | Reads files created during Call 1 |
Both calls use the same sandbox name:
THREAD_ID = "ds-pipeline-demo-1"
SANDBOX_NAME = f"thread-{THREAD_ID}"
This produces the following sandbox name:
thread-ds-pipeline-demo-1
When Call 2 begins, the program searches for a Daytona sandbox with that name. If one exists, the program reuses it instead of creating a new environment.
Understanding the two types of persistence
It is important to separate conversation state from sandbox state.
| State type | Stored by | What it contains | Key used in this example |
|---|---|---|---|
| Conversation memory | LangGraph checkpointer | Earlier messages and agent state | thread_id |
| Execution environment | Daytona sandbox | Files, installed packages, and workspace state | thread-<thread_id> |
The same thread_id serves two different purposes.
config = {"configurable": {"thread_id": THREAD_ID}}
This gives LangGraph a stable identifier for the conversation.
The sandbox name is then derived from the same value:
SANDBOX_NAME = f"thread-{THREAD_ID}"
This gives Daytona a predictable name for the remote workspace.
A thread_id does not automatically make Daytona reuse a sandbox. The application must implement a mapping strategy. In this example, the mapping is a stable sandbox name.
Architecture
The overall flow looks like this:
User prompt
|
v
Deep Agent running in the application
|
+-- LangGraph checkpointer
| |
| +-- Stores conversation state using thread_id
|
v
DaytonaSandbox backend
|
+-- File operations
+-- Shell command execution
|
v
Remote Daytona sandbox
|
+-- /home/daytona/train.py
+-- /home/daytona/data.csv
+-- /home/daytona/model.pkl
+-- /home/daytona/chart.png
The agent runs in the application, while filesystem and shell operations run in the Daytona environment.
Prerequisites
Install the required packages:
pip install daytona langchain-daytona deepagents \
langchain-nvidia-ai-endpoints python-dotenv
Create a .env file:
DAYTONA_API_KEY=your_daytona_api_key
NVIDIA_API_KEY=your_nvidia_api_key
The .env file and API keys should not be committed to version control.
Step 1: Create shared configuration
Start by loading environment variables and setting up the Daytona client.
from dotenv import load_dotenv
from daytona import Daytona, CreateSandboxFromSnapshotParams
from langchain_daytona import DaytonaSandbox
from langgraph.checkpoint.memory import InMemorySaver
from deepagents import create_deep_agent
load_dotenv()
DAYTONA = Daytona()
# This checkpointer stays alive during this Python process.
CHECKPOINTER = InMemorySaver()
# One ID represents one conversation and one sandbox workspace.
THREAD_ID = "ds-pipeline-demo-1"
SANDBOX_NAME = f"thread-{THREAD_ID}"
# Remove the sandbox after 60 minutes of inactivity.
SANDBOX_TTL_MINUTES = 60
What does InMemorySaver() do?
InMemorySaver() stores LangGraph state in memory. Since both agent calls happen inside the same Python script, the second call can access the conversation state created by the first call.
There is an important limitation:
InMemorySaver()is not durable. If the application stops and starts again, the conversation memory is lost.
The Daytona sandbox may still be reusable if it has not expired, because it is found by name. However, a production application should use a persistent LangGraph checkpointer backed by a database or other durable storage service.
Step 2: Find or create a sandbox
This function is the core of the sandbox reuse pattern:
def get_or_create_sandbox(name: str):
"""Reuse a sandbox with this name, or create it if missing."""
for sb in DAYTONA.list():
if getattr(sb, "name", None) == name:
print(
f"Reusing existing sandbox: "
f"{name} (id={sb.id}, state={sb.state})"
)
# Start it again if it is stopped or paused.
if str(sb.state).lower() not in ("running", "started"):
sb.start()
return sb
print(
f"Creating new sandbox: {name} "
f"(ttl={SANDBOX_TTL_MINUTES}min)..."
)
sb = DAYTONA.create(
CreateSandboxFromSnapshotParams(
name=name,
language="python",
ttl_minutes=SANDBOX_TTL_MINUTES,
)
)
print(f"Created sandbox: {name} (id={sb.id})")
return sb
Here is what happens in different situations:
| Situation | Result |
|---|---|
| No sandbox with the target name exists | A new sandbox is created |
| A matching sandbox exists and is running | It is reused immediately |
| A matching sandbox exists but is stopped | It is started and reused |
| The sandbox expired because of TTL | A new sandbox is created |
Files and installed packages exist inside the Daytona sandbox, not in the LangGraph checkpointer.
That is why Call 2 can load the model:
Call 1 writes /home/daytona/model.pkl
|
v
The same Daytona sandbox is reused
|
v
Call 2 reads /home/daytona/model.pkl
Step 3: Give the agent access to the sandbox
Next, the Daytona sandbox is wrapped in a Deep Agents backend.
def build_agent(sandbox):
"""Create an AI agent connected to one Daytona sandbox."""
backend = DaytonaSandbox(sandbox=sandbox)
return create_deep_agent(
model="nvidia:nvidia/nemotron-3-ultra-550b-a55b",
backend=backend,
checkpointer=CHECKPOINTER,
system_prompt=(
"You are a data-science coding assistant with sandbox access. "
"Use the filesystem and execute tools to install packages, "
"write scripts, run them, and inspect outputs. "
"Always verify files exist before reporting success.\n\n"
"The writable working directory is /home/daytona. "
"Use absolute paths under /home/daytona/ for file operations. "
"When executing commands, first run: cd /home/daytona"
),
)
The sandbox backend gives the agent tools for tasks such as:
- Writing files
- Reading files
- Editing files
- Listing directories
- Searching files
- Running shell commands
These tools allow the agent to install packages, create Python scripts, run them, and inspect generated artifacts.
Why /home/daytona matters
The system prompt directs the agent to work only inside:
/home/daytona
This convention keeps generated files in one writable and predictable location.
For example:
/home/daytona/train.py
/home/daytona/data.csv
/home/daytona/model.pkl
/home/daytona/chart.png
Using absolute paths also reduces the chance of files being written to an unexpected location or to a protected root-level directory.
Step 4: Run the training turn
For the first call, the agent receives instructions to install dependencies, create a training script, execute it, and verify the outputs.
call1_prompt = (
"Do the following in the sandbox, step by step:\n"
"1. Install: pandas matplotlib scikit-learn (use pip).\n"
"2. Write a Python script `train.py` that:\n"
" - generates a small synthetic classification dataset,\n"
" - saves the data to `data.csv`,\n"
" - trains a sklearn LogisticRegression model,\n"
" - saves the trained model to `model.pkl`,\n"
" - creates and saves a scatter plot as `chart.png`.\n"
"3. Run `train.py` and verify `model.pkl` and `chart.png` exist.\n"
"4. Report what you did and the file sizes."
)
Inside the sandbox, the workflow is similar to:
cd /home/daytona
pip install pandas matplotlib scikit-learn
python train.py
ls -lh model.pkl chart.png
After the first call, the Daytona workspace should contain:
/home/daytona/
├── train.py
├── data.csv
├── model.pkl
└── chart.png
Step 5: Reuse the sandbox in the second call
Before the second call, the same lookup function runs again:
sandbox = get_or_create_sandbox(SANDBOX_NAME)
agent = build_agent(sandbox)
Since SANDBOX_NAME is unchanged, the program finds and reuses the existing environment.
The next prompt uses artifacts from the earlier turn:
call2_prompt = (
"Using the artifacts from the previous turn in the sandbox:\n"
"1. Load `model.pkl` and run a prediction on this new row:\n"
" feature_1 = 0.5, feature_2 = -1.2\n"
" Print the predicted class.\n"
"2. Confirm `chart.png` is still present and report its size and "
"the list of files in the working directory.\n"
"This proves the installed packages and files survived between turns."
)
A successful second call demonstrates that:
scikit-learn is still installed
model.pkl still exists
chart.png still exists
This is the practical value of a thread-scoped sandbox.
Streaming tool activity
The run_turn() function streams the agent’s actions while work is in progress:
for chunk in agent.stream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
stream_mode="updates",
):
...
This makes the workflow observable:
[agent] -> execute({"command": "cd /home/daytona && pip install ..."})
[tools] <- execute: Successfully installed ...
[agent] -> write_file({"path": "/home/daytona/train.py", ...})
[agent] -> execute({"command": "cd /home/daytona && python train.py"})
[tools] <- execute: Model saved successfully
Streaming is useful for code-running agents because it shows:
- Which commands were executed
- Which files were written
- Whether a command failed
- Whether the agent verified the final files
Step 6: Clean up the sandbox
The example uses a finally block to stop the sandbox after the script ends:
finally:
try:
sb = DAYTONA.get(SANDBOX_NAME)
sb.stop()
print(f"Stopped sandbox: {SANDBOX_NAME} (id={sb.id})")
except Exception as exc:
print(f"Could not stop sandbox {SANDBOX_NAME}: {exc}")
This matters because sandboxes consume remote compute resources.
The example also configures:
ttl_minutes=60
The TTL is a backup cleanup mechanism. If the application crashes or the session does not return, the sandbox can be removed after it remains idle for the configured period.
In this demo, the explicit stop() runs after Call 2, so the TTL mainly serves as a fallback.
Security considerations
A sandbox provides isolation between agent-generated code and the local machine or primary application server.
However, a sandbox does not solve every security issue automatically.
| Risk | Why it still matters |
|---|---|
| Prompt injection | Untrusted input could convince an agent to run unwanted commands inside the sandbox |
| Network access | If outbound networking is allowed, the agent may send data elsewhere |
| Secrets in prompts | Sensitive information passed to the agent could be written into sandbox files |
| Resource usage | An agent may install large packages, create large files, or run expensive commands |
Practical safety rules include:
- Keep API keys outside the sandbox when possible.
- Avoid placing sensitive production data into the sandbox unless necessary.
- Use short sandbox TTL values.
- Use unique and difficult-to-guess thread IDs in multi-user systems.
- Validate user-provided file paths and commands.
- Apply runtime, storage, package, and network restrictions where available.
- Clean up environments when tasks finish.
Full code
"""
Thread-scoped Daytona Sandbox: multi-turn data science pipeline.
Demonstrates sandbox + conversation reuse across two calls with the
same `thread_id`:
Call 1: install pandas/matplotlib/scikit-learn, generate a CSV, train a
small model, save `model.pkl` + `chart.png`.
Call 2: load the saved model and predict on a new row; re-display the
chart. Proves both the installed packages and the files
survived between turns.
How reuse works:
- The sandbox is named `thread-<thread_id>`. On Call 2 we list existing
sandboxes and find it by name -> reuse it (files + installed packages
are still there). If not found, we create it.
- A LangGraph checkpointer keyed by the same `thread_id` keeps the
conversation memory across the two invocations.
- `ttl_minutes` auto-deletes the sandbox after idle time so you don't
pay forever if you never come back.
Prereqs (already in pyproject.toml):
pip install daytona langchain-daytona deepagents langchain-nvidia-ai-endpoints
Env (already in .env):
DAYTONA_API_KEY - for Daytona()
NVIDIA_API_KEY - for the LLM
Run:
python daytona_thread_scoped.py
"""
from dotenv import load_dotenv
from daytona import Daytona, CreateSandboxFromSnapshotParams
from langchain_daytona import DaytonaSandbox
from langgraph.checkpoint.memory import InMemorySaver
from deepagents import create_deep_agent
# Load DAYTONA_API_KEY / NVIDIA_API_KEY from .env
load_dotenv()
DAYTONA = Daytona()
# A single checkpointer kept alive for the whole script so both calls
# share conversation memory keyed by thread_id.
CHECKPOINTER = InMemorySaver()
# The conversation/sandbox key. Same value on both calls => reuse.
THREAD_ID = "ds-pipeline-demo-1"
SANDBOX_NAME = f"thread-{THREAD_ID}"
SANDBOX_TTL_MINUTES = 60 # auto-delete after 1h idle
def get_or_create_sandbox(name: str):
"""Find an existing Daytona sandbox by name, or create a new one.
Returns the (running) Sandbox. Reusing the same name across turns is
what makes files and installed packages persist between calls.
"""
for sb in DAYTONA.list():
if getattr(sb, "name", None) == name:
print(f"Reusing existing sandbox: {name} (id={sb.id}, state={sb.state})")
# Make sure it's running (it may have been stopped/paused).
if str(sb.state).lower() not in ("running", "started"):
sb.start()
return sb
print(f"Creating new sandbox: {name} (ttl={SANDBOX_TTL_MINUTES}min)...")
sb = DAYTONA.create(
CreateSandboxFromSnapshotParams(
name=name,
language="python",
ttl_minutes=SANDBOX_TTL_MINUTES,
)
)
print(f"Created sandbox: {name} (id={sb.id})")
return sb
def build_agent(sandbox):
"""Build a Deep Agent bound to the given sandbox backend."""
backend = DaytonaSandbox(sandbox=sandbox)
return create_deep_agent(
model="nvidia:nvidia/nemotron-3-ultra-550b-a55b",
backend=backend,
checkpointer=CHECKPOINTER,
system_prompt=(
"You are a data-science coding assistant with sandbox access. "
"Use the provided filesystem and execute tools to install "
"packages, write scripts, run them, and read outputs. "
"Always verify results (e.g. check that a file exists) before "
"reporting success. Keep shell commands short.\n\n"
"IMPORTANT -- working directory:\n"
"- The sandbox's writable working directory is `/home/daytona`. "
"The filesystem root `/` is NOT writable.\n"
"- When using write_file / read_file / edit_file, ALWAYS pass "
"absolute paths under `/home/daytona/` "
"(e.g. `/home/daytona/train.py`, NOT `train.py` or `/train.py`).\n"
"- When using the execute tool, first `cd /home/daytona` so that "
"relative paths in shell commands also land there.\n"
"- Keep all created files (scripts, data.csv, model.pkl, "
"chart.png) under `/home/daytona/`."
),
)
def run_turn(agent, config, label: str, prompt: str) -> str:
"""Invoke the agent for one turn, streaming tool calls and results."""
print("\n" + "=" * 72)
print(f"{label}")
print("=" * 72)
print(f"USER: {prompt}\n")
final_text = ""
for chunk in agent.stream(
{"messages": [{"role": "user", "content": prompt}]},
config=config,
stream_mode="updates",
):
for node_name, update in chunk.items():
if not update:
continue
for msg in update.get("messages", []):
if msg.type == "ai" and getattr(msg, "tool_calls", None):
for call in msg.tool_calls:
name = call.get("name")
args = call.get("args", {})
summary = str(args)
if len(summary) > 200:
summary = summary[:200] + "..."
print(f"[{node_name}] -> {name}({summary})")
elif msg.type == "tool":
content = str(msg.content)
if len(content) > 300:
content = content[:300] + "..."
print(f"[{node_name}] <- {msg.name}: {content}")
elif msg.type == "ai" and not getattr(msg, "tool_calls", None):
final_text = msg.content
print(f"[{node_name}] AI: {final_text}\n")
return final_text
def main():
config = {"configurable": {"thread_id": THREAD_ID}}
# --- Call 1: create sandbox + run the data-science pipeline -------
sandbox = get_or_create_sandbox(SANDBOX_NAME)
agent = build_agent(sandbox)
call1_prompt = (
"Do the following in the sandbox, step by step:\n"
"1. Install: pandas matplotlib scikit-learn (use pip).\n"
"2. Write a Python script `train.py` that:\n"
" - generates a small synthetic classification dataset (e.g. "
"make_classification, ~200 rows, 2 numeric features),\n"
" - saves the data to `data.csv`,\n"
" - trains a sklearn LogisticRegression model,\n"
" - saves the trained model to `model.pkl` using joblib/pickle,\n"
" - plots the two features colored by class and saves it as "
"`chart.png` (do not call plt.show).\n"
"3. Run `train.py` and verify `model.pkl` and `chart.png` exist.\n"
"4. Report what you did and the file sizes."
)
run_turn(agent, config, "CALL 1: train + save artifacts", call1_prompt)
# --- Call 2: reuse sandbox + memory; prove files survived ---------
# Same thread_id => same checkpointer memory AND same sandbox.
sandbox = get_or_create_sandbox(SANDBOX_NAME)
agent = build_agent(sandbox) # rebuild bound to the (reused) sandbox
call2_prompt = (
"Using the artifacts from the previous turn in the sandbox:\n"
"1. Load `model.pkl` and run a prediction on this new row:\n"
" feature_1 = 0.5, feature_2 = -1.2\n"
" Print the predicted class.\n"
"2. Confirm `chart.png` is still present and report its size and "
"the list of files in the working directory.\n"
"This proves the installed packages and files survived between turns."
)
run_turn(agent, config, "CALL 2: load model + reuse files", call2_prompt)
if __name__ == "__main__":
try:
main()
finally:
# Clean up the sandbox so it doesn't keep running / billing.
# (ttl_minutes would also clean it up, but we stop it now to be safe.)
try:
sb = DAYTONA.get(SANDBOX_NAME)
sb.stop()
print(f"\nStopped sandbox: {SANDBOX_NAME} (id={sb.id})")
except Exception as exc:
print(f"\nCould not stop sandbox {SANDBOX_NAME}: {exc}")
Final takeaway
A thread-scoped sandbox gives each AI conversation its own isolated workspace.
In this example:
-
THREAD_IDidentifies the conversation. -
thread-<THREAD_ID>identifies the Daytona sandbox. - Call 1 installs packages and creates artifacts.
- Call 2 finds the same sandbox and reuses its packages and files.
- LangGraph memory preserves conversational context during the script.
- Daytona TTL and explicit cleanup help control resource usage.
This pattern can be used for more than a small machine-learning example:
- AI coding assistants
- Data analysis agents
- File-processing workflows
- Report and chart generation
- Automated testing environments
- Per-user development workspaces
Conversation state and execution state are separate, but a shared thread identifier can connect them in a clean and reliable way.
Top comments (0)