Skip to content
Guides · Integrations

Give a LangGraph Agent a Python Sandbox

Updated October 27, 2026 .md

Short answer: Write a LangGraph tool that sends the model's Python to a Sandboxer microVM with the Python SDK and returns stdout, stderr and the exit code. Key the sandbox by the graph's thread_id, so each conversation keeps its own files, give every run a timeout, and delete or stop the sandboxes when the graph is done.

At a glance#

Decision What this guide does Why
How code runs A run_python tool: write main.py, run python3 main.py with sandbox.exec The model writes a whole program per call, which is the CodeAct pattern
Where it runs A small Sandboxer sandbox: an Ubuntu 24.04 microVM with python3, pip and uv The model's code never touches the process that runs your graph
Sandbox per LangGraph thread_id Files from turn 1 are still there in turn 2; two users never share a disk
Runaway code timeout_seconds on every exec (1 to 600) A while True loop costs seconds, not the whole turn
Forgotten sandboxes ttl_seconds=1800 on create, delete() in finally The sandbox stops itself even if your process crashes

What we ran, and what we didn't#

We have no LLM key on the test account, so the model in this guide is a stub: a graph node that always emits the same tool call, then copies the tool's answer into the reply. Everything after the model is real. The stub's tool call goes through LangGraph's ToolNode to the tool, the tool calls the Sandboxer API, and the program runs in a new microVM on production. We also called the tool directly, with no graph. The outputs below are from that run (langgraph 1.2.18, langchain-core 1.7.0, sandboxer-sdk 0.1.1, Python 3.12). To use a real model, swap the stub node for a chat model bound to [run_python]; nothing else changes.

1. Install#

python3 -m venv .venv && . .venv/bin/activate
pip install langgraph langchain-core sandboxer-sdk
export SANDBOXER_API_KEY=sbx_live_…

Install sandboxer-sdk, not sandboxer: that PyPI name belongs to an unrelated project. Give the graph a scoped key that can create sandboxes and delete only the ones it created: sbx api-key create agent --preset ci.

2. The tool and the graph#

Save this as codeact.py.

import os
import threading

from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from sandboxer import Sandboxer

client = Sandboxer()  # reads SANDBOXER_API_KEY
PREFIX = os.environ.get("SANDBOX_PREFIX", "langgraph-")
_sandboxes = {}  # thread_id -> Sandbox
_lock = threading.Lock()


def sandbox_for(thread_id: str):
    """One sandbox per LangGraph thread, created on first use."""
    with _lock:
        if thread_id not in _sandboxes:
            _sandboxes[thread_id] = client.create(
                type="small",
                ttl_seconds=1800,  # stops itself if this process dies
                name=f"{PREFIX}{thread_id}",
            )
        return _sandboxes[thread_id]


def close_all():
    for thread_id, sandbox in _sandboxes.items():
        sandbox.delete()  # or sandbox.stop() to keep files for later
        print(f"deleted {sandbox.id} ({thread_id})")
    _sandboxes.clear()


@tool
def run_python(code: str, config: RunnableConfig) -> str:
    """Run a Python 3 program in this conversation's cloud sandbox and return its output.
    Files and installed packages persist between calls in the same conversation."""
    sandbox = sandbox_for(config["configurable"]["thread_id"])
    sandbox.files.write("main.py", code)
    r = sandbox.exec("python3 main.py", timeout_seconds=int(os.environ.get("RUN_TIMEOUT", "120")))
    status = "timed out" if r.timed_out else f"exit code {r.exit_code}"
    out = (r.stdout + r.stderr)[-4000:]  # keep the model's context small
    return f"[{sandbox.id}] {status}\n{out}".strip()


# A stand-in for the model: it always asks for the same tool call, then reports the result.
# Replace this node with a real chat model bound to [run_python].
PROGRAM = """
import json
n = 1_000_000
sieve = bytearray([1]) * n
sieve[0:2] = b"\\x00\\x00"
for i in range(2, int(n ** 0.5) + 1):
    if sieve[i]:
        sieve[i * i::i] = bytearray(len(range(i * i, n, i)))
primes = [i for i, p in enumerate(sieve) if p]
gaps = [b - a for a, b in zip(primes, primes[1:])]
json.dump(primes, open("primes.json", "w"))
print("primes below 1,000,000:", len(primes))
print("largest gap:", max(gaps), "after", primes[gaps.index(max(gaps))])
"""


def fake_model(state: MessagesState):
    last = state["messages"][-1]
    if isinstance(last, ToolMessage):
        return {"messages": [AIMessage(content=f"Tool said:\n{last.content}")]}
    code = PROGRAM if last.content.startswith("count the primes") else last.content
    return {"messages": [AIMessage(content="", tool_calls=[
        {"name": "run_python", "args": {"code": code}, "id": f"call_{len(state['messages'])}"}
    ])]}


builder = StateGraph(MessagesState)
builder.add_node("agent", fake_model)
builder.add_node("tools", ToolNode([run_python]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile(checkpointer=InMemorySaver())


def ask(thread_id: str, text: str):
    config = {"configurable": {"thread_id": thread_id}}
    result = graph.invoke({"messages": [{"role": "user", "content": text}]}, config)
    print(f"--- thread {thread_id}: {result['messages'][-1].content}\n")


if __name__ == "__main__":
    try:
        # 1. The tool on its own, no graph
        print(run_python.invoke(
            {"code": "import platform, os; print(platform.python_version(), os.getcwd())"},
            config={"configurable": {"thread_id": "1"}},
        ), "\n")
        # 2. Graph -> tool -> sandbox, same thread, so the same sandbox
        ask("1", "count the primes below 1,000,000")
        ask("1", "import json; print(len(json.load(open('primes.json'))), 'primes still on disk')")
        # 3. A second thread gets its own sandbox
        ask("2", "import os; print(os.path.exists('primes.json'))")
        # 4. A runaway program hits the timeout
        os.environ["RUN_TIMEOUT"] = "5"
        ask("2", "import time\nwhile True: time.sleep(1)")
    finally:
        close_all()

The model only sees one argument. config is the name LangChain reserves for passing the run's RunnableConfig into a tool, so it is left out of the tool's schema (run_python.args is {'code': …}), and ToolNode fills it in with the graph's thread_id. See LangChain's tools guide for @tool, docstrings as descriptions and injected arguments, and LangGraph's persistence guide for threads and checkpointers.

3. Run it#

python3 codeact.py
[sb_g6jl6xmzhc] exit code 0
3.12.3 /home/user

--- thread 1: Tool said:
[sb_g6jl6xmzhc] exit code 0
primes below 1,000,000: 78498
largest gap: 114 after 492113

--- thread 1: Tool said:
[sb_g6jl6xmzhc] exit code 0
78498 primes still on disk

--- thread 2: Tool said:
[sb_lqkqv4gsj5] exit code 0
False

--- thread 2: Tool said:
[sb_lqkqv4gsj5] timed out

deleted sb_g6jl6xmzhc (1)
deleted sb_lqkqv4gsj5 (2)

The whole script took about 12 seconds in our single run on October 27, 2026, including creating two sandboxes and deleting them. Reading the output:

  • The direct call created thread 1's sandbox. The code ran as Python 3.12.3 in /home/user, the sandbox's home directory.
  • The graph's first turn on thread 1 reused that sandbox (same id) and wrote primes.json.
  • The second turn on thread 1 read primes.json back. Files persist across turns of a thread.
  • Thread 2 got a different sandbox, where primes.json doesn't exist.
  • The infinite loop returned timed out after the 5-second limit instead of hanging the graph.

One sandbox per thread#

Keying sandboxes by thread_id matches how LangGraph already scopes a conversation. In a server that handles many users, every user's thread gets its own VM, so one user's code can't read another's files. The in-memory dict above lives as long as the process. For a graph that pauses for hours or across restarts, store sandbox.id in the graph state or next to your checkpointer, reopen it with client.get(sandbox_id), and call resume() first if it has stopped. The disk and installed packages are still there. The LangChain and LangGraph docs page shows the same pattern.

If you'd rather not use the reserved config name, LangGraph's ToolRuntime works too: add runtime: ToolRuntime (from langgraph.prebuilt) to the tool's signature and read runtime.execution_info.thread_id. We checked that it returns the thread id inside a graph. A tool written that way can't be called on its own without building a runtime, which is why this guide uses config.

Timeouts#

There are two clocks, and you want both:

  • Per call: exec(..., timeout_seconds=N) waits at most N seconds (1 to 600, default 30) and sets timed_out on the result. Tell the model in the tool output that it timed out, so it can try a cheaper approach.
  • Per sandbox: ttl_seconds on create stops the sandbox after that long (default one hour). This is the safety net for a process that dies before finally runs.

For work longer than 10 minutes, start it detached with sandbox.start("python3 main.py") and give the model a second tool that checks command_status. The Python SDK docs cover detached commands and streaming.

Cleanup#

Pick one per use case:

Call Files kept Billing Use it when
sandbox.delete() No Ends Throwaway runs, tests, one-shot jobs
sandbox.stop() Yes, and memory Ends while stopped The thread will continue later
Neither (TTL only) Yes, after auto-stop Until the TTL fires Never on purpose; it's the fallback

Call it in a finally block, as the script does, or when your app ends a session. After the run above, sbx list showed none of the run's sandboxes, and both ids returned 404.

FAQ#

Does this work with a real model?#

Yes. Replace fake_model with a node that calls a chat model bound to [run_python] (for example model.bind_tools([run_python])). The tool, the sandbox handling and the graph edges stay the same. We tested only the stub path, since we had no LLM key.

Can the agent install packages?#

Yes. The sandbox runs as a user with root through sudo, and pip and uv are installed. A package the agent installs in one turn is still there in the next turn of the same thread.

Why not run the code with exec() in my own process?#

The model's code would then run with your process's files, network and credentials. In a sandbox it runs in a separate microVM, and the worst case is a broken sandbox you delete.

How do I get a chart or file back?#

Have the program save it, then read it with sandbox.files.read_bytes("plot.png") (up to 5 MiB) or sandbox.files.download(...) for larger files (up to 50 MiB).

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide