# Give a LangGraph Agent a Python Sandbox

> 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.

Source: https://sandboxer.com/guides/give-a-langgraph-agent-a-python-sandbox. Updated 2026-10-27.

**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

```bash
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`.

```python
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](https://docs.langchain.com/oss/python/langchain/tools) for `@tool`, docstrings as descriptions and injected arguments, and LangGraph's [persistence guide](https://docs.langchain.com/oss/python/langgraph/persistence) for threads and checkpointers.

## 3. Run it

```bash
python3 codeact.py
```

```text
[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](/docs/langchain) 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](/docs/sdk-python) 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).

## Related

- [LangChain and LangGraph](/docs/langchain)
- [Python SDK](/docs/sdk-python)
- [MCP server](/docs/mcp)
- [Move files in and out of a sandbox](/guides/move-files-in-and-out-of-a-sandbox)
