all posts

How to Give a LangGraph Agent a Code Execution Tool

Ajay Kumar··9 min read

Most LangGraph code tools are copied out of a one-shot ReAct tutorial, and it shows. A ReAct loop is a while-loop with a message list; a LangGraph app is a graph with persisted state, a checkpointer, threads that live for days, and interrupts that stop the world halfway through a run. Those four things change the design of a code execution tool more than the tool body itself ever does. The function that runs the code is thirty lines. Deciding where the sandbox lives, and what happens when someone resumes a thread on Monday that was interrupted on Friday, is the actual work.

I'm Ajay, I build PandaStack. This post is the practical version: the tool, the three lifecycle patterns and which one to pick, the checkpointing trap that nearly every integration walks into, streaming long commands back to the user, and where a human-in-the-loop interrupt belongs. The sandbox calls are ours because that's what I know in detail; the graph shapes work against any provider that gives you an isolated machine over HTTP.

In-process execution is not a sandbox

The naive tool is a function that calls exec() or subprocess.run() on the code the model just wrote. It works on the first try, which is why it survives into production. What you have built is a remote code execution endpoint whose authentication is the model's good judgement: the code inherits your environment variables, your cloud metadata endpoint, your database credentials and your outbound network position. A prompt injection in a scraped page becomes shell on your API server. Reaching for a container helps with the tidiness and not much with the boundary — a container is a polite suggestion to a kernel you are still sharing with the model's output. The isolation you actually want is a virtualisation boundary: a separate kernel, a separate machine, one that you can destroy on a whim, because a model-authored rm -rf should be a boring log line rather than an incident channel.

That's the entire security argument and I'll leave it there. The rest of this post assumes the execution happens somewhere else and concerns itself with the part that's specific to LangGraph.

The tool itself

A LangGraph tool returns a string that goes straight into the model's context, so the return shape is a prompt-engineering decision disguised as a serialisation one. Three rules: always return the error text instead of raising, always include the exit code, and truncate long output from the middle rather than the end — a failing build puts its useful line at the bottom, and a naive head-truncation throws away exactly the part the model needed.

# pip install pandastack langgraph langchain-core
from langchain_core.tools import tool
from langchain_core.runnables import RunnableConfig
from pandastack import Sandbox, SandboxTimeout

MAX_CHARS = 6000

def _clip(s: str) -> str:
    if len(s) <= MAX_CHARS:
        return s
    head, tail = s[: MAX_CHARS // 2], s[-MAX_CHARS // 2 :]
    return head + "\n...[truncated]...\n" + tail

@tool
def run_shell(command: str, config: RunnableConfig) -> str:
    """Run a shell command in a secure sandbox and return its output.

    The sandbox persists for this conversation: files you write and packages
    you install are still there on the next call. Python and Node are
    available. Use this for any calculation, file work, or code the task needs.
    """
    sbx = sandbox_for_thread(config["configurable"]["thread_id"])
    try:
        r = sbx.exec(command, timeout_seconds=60)
    except SandboxTimeout:
        return "exit_code: 124\nerror: command exceeded the 60s limit and was killed."
    return (
        "exit_code: " + str(r.exit_code)
        + "\nstdout:\n" + _clip(r.stdout)
        + "\nstderr:\n" + _clip(r.stderr)
    )

The docstring is not documentation, it is the tool description the model reads before deciding to call you. The sentence about persistence is the one that earns its place: without it, models assume a fresh process and defensively re-install and re-import everything every single turn, which costs tokens and produces longer, more fragile generated code.

Note the config parameter. LangGraph injects the run config into any tool that declares it, which is how you get the thread_id without threading it through the model's arguments. Never let the model pass its own sandbox identifier — that's a tenancy boundary you'd be handing to a token predictor.

Wiring it into the graph

The prebuilt agent is a fine place to start, and the explicit StateGraph is what you'll end up with once you want an approval step or a custom router. Both use the same tool.

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.postgres import PostgresSaver
from langchain_anthropic import ChatAnthropic

class State(TypedDict):
    messages: Annotated[list, add_messages]
    sandbox_id: str | None      # the handle, carried in state

llm = ChatAnthropic(model="claude-sonnet-4-5").bind_tools([run_shell])

def call_model(state: State):
    return {"messages": [llm.invoke(state["messages"])]}

g = StateGraph(State)
g.add_node("model", call_model)
g.add_node("tools", ToolNode([run_shell]))
g.add_edge(START, "model")
g.add_conditional_edges("model", tools_condition, {"tools": "tools", END: END})
g.add_edge("tools", "model")

with PostgresSaver.from_conn_string(DSN) as saver:
    app = g.compile(checkpointer=saver)
    cfg = {"configurable": {"thread_id": "user-42:conv-7"}}
    out = app.invoke({"messages": [("user", "How many rows in /data/orders.csv?")]}, cfg)
    print(out["messages"][-1].content)
If you are using the prebuilt create_react_agent instead, nothing above the compile line changes: pass tools=[run_shell] and a checkpointer, and the thread_id still arrives in the tool's config. The explicit graph is worth building the moment you want an interrupt before a destructive call, which is the last section of this post.

Three lifecycle patterns

This is the decision that determines everything else. There are three honest options.

  • Per-tool-call ephemeral — create a sandbox at the top of the tool, kill it in a finally. Simplest possible thing, no leaked state between calls, no cleanup problem at all. The agent cannot carry anything between calls: a pip install in step one is gone by step two, and so is the file it just wrote.
  • Per-thread persistent — one sandbox per conversation, its id stored in graph state and re-attached on every call. Installs and files survive across turns, the agent works the way a person works at a terminal, and the isolation boundary lines up exactly with the tenancy boundary you already have. Costs you a lifecycle to manage.
  • Per-run pooled — a pool of warm sandboxes handed out per graph run and returned afterwards. Amortises startup across users, which is the only reason anyone does it, and reintroduces the problem you started with: user A's leftover files are user B's starting conditions unless your reset path is perfect. It never is.

For anything conversational, use per-thread. The usual argument for pooling is startup cost, and that argument is entirely a function of your provider's numbers. A snapshot-restore create on PandaStack is 179ms at p50 and around 203ms at p99, which is quietly the load-bearing fact here: at a fifth of a second, per-thread creation disappears into the model's own first-token latency, and even per-call ephemeral is defensible for a tool that genuinely doesn't need carry-over. If your sandbox takes ten seconds to appear, you will be pushed toward pooling by simple arithmetic, and then you will spend a quarter debugging cross-tenant state. Check the number before you design around it.

The one case where per-call ephemeral genuinely wins: a tool that evaluates untrusted third-party code as data — a user-submitted snippet, a candidate's homework, a generated migration you want to test in a vacuum. There, carry-over is a bug and not a feature.

Where the sandbox id lives

Put the id in graph state, not in a module-level dictionary. A dict keyed by thread_id works beautifully until you run two web workers, at which point half your requests land on a process that has never heard of that conversation and silently create a second sandbox. Graph state is already persisted by the checkpointer, already keyed by thread, and already visible when you inspect a thread to debug it.

The tool can't write to graph state directly in the simple form above, so the pattern I use is: the tool resolves and returns the sandbox through a helper, and a small node before the model writes the resolved id back into state. If you'd rather keep it in one place, LangGraph's InjectedState and Command return values let a tool update state itself — more moving parts, same outcome.

A checkpoint restores your state, not your VM

Here is the bit most integrations get wrong, and it fails in production rather than in your tests, because your tests never leave a thread alone for six hours.

LangGraph's checkpointer serialises graph state. It does not serialise a machine. The string sitting in state["sandbox_id"] is a claim about the world that was true when it was written and has a shelf life: TTLs expire, hosts are replaced, an idle reaper does its job, someone runs a cleanup script. Resume a thread that was interrupted overnight and the id in the checkpoint points at nothing at all. An integration that assumes the handle is live throws a 404 out of a tool call, the graph raises, and your user sees a stack trace as the answer to a question they asked yesterday.

The fix is not clever, it's just discipline: treat every stored sandbox id as a cache entry that may have been evicted. Verify, recreate on miss, and re-seed whatever the new sandbox needs to be useful.

from pandastack import Sandbox, NotFoundError

def attach_or_create(sandbox_id: str | None, thread_id: str) -> Sandbox:
    """Return a LIVE sandbox. Never trust an id from a checkpoint."""
    if sandbox_id:
        try:
            sbx = Sandbox.get(sandbox_id)
            if sbx.status == "running":
                return sbx
        except NotFoundError:
            pass          # expired, reaped, or the host went away
    sbx = Sandbox.create(
        template="base",
        ttl_seconds=3600,
        metadata={"thread": thread_id},
    )
    reseed(sbx)           # re-upload inputs the conversation depends on
    return sbx

def reseed(sbx: Sandbox) -> None:
    sbx.filesystem.write("/data/orders.csv", ORDERS_CSV)
    sbx.exec("pip install -q pandas", timeout_seconds=180)
The corollary people miss: after a recreate, the model's context still contains a transcript claiming it installed scipy and wrote a file. It will confidently reference both. Either re-seed to make the claim true again, or return a line in the tool output saying the environment was rebuilt and prior files are gone. Silently handing the model a fresh machine while its notes describe the old one produces the most baffling class of agent bug there is.

Two habits make this cheap. Set a TTL at creation so an abandoned thread cleans itself up without you writing a reaper, and keep re-seeding idempotent and fast so recreation is never a big deal. And if a conversation's environment is genuinely expensive to rebuild — a large dataset, a long install — snapshot the sandbox at the end of a turn and restore from that snapshot instead of rebuilding from scratch. That's a fork of a known-good machine rather than a re-run of a fragile setup script.

Streaming output, and a hard timeout

A tool call that takes ninety seconds and shows nothing is indistinguishable from a hung app. LangGraph's custom stream mode lets a tool push progress out of the graph while it is still executing, which pairs exactly with a streaming exec on the sandbox side.

from langgraph.config import get_stream_writer

@tool
def run_build(command: str, config: RunnableConfig) -> str:
    """Run a long command (build, test suite, install) and stream its output."""
    writer = get_stream_writer()
    sbx = sandbox_for_thread(config["configurable"]["thread_id"])
    lines: list[str] = []

    def emit(chunk: str) -> None:
        lines.append(chunk)
        writer({"log": chunk})          # -> stream_mode="custom"

    code = sbx.exec_stream(command, on_stdout=emit, on_stderr=emit,
                           timeout_seconds=600)
    return "exit_code: " + str(code) + "\noutput:\n" + _clip("".join(lines))

# consume it
for mode, chunk in app.stream(inputs, cfg, stream_mode=["custom", "updates"]):
    if mode == "custom":
        print(chunk["log"], end="")

Note the two different timeouts. Sixty seconds on the interactive tool, ten minutes on the explicit build tool. Both are non-negotiable, because a model that writes an accidental while True will otherwise hold the request open until something upstream gives up — and the something upstream is frequently a load balancer with a much longer idle timeout than you'd like. A bounded timeout means a runaway loop costs you sixty seconds of one vCPU rather than a weekend of a machine you forgot about. If your sandbox is a microVM you can kill outright, the timeout is enforceable rather than aspirational; an in-process timer against code running in your own interpreter is theatre.

Interrupts, and why the boundary lets you say yes more

LangGraph's interrupt is the cleanest human-in-the-loop primitive in any agent framework: call it inside a node, the graph checkpoints and stops, and resuming with a Command carries the human's answer back into the same call. Put one in front of the operations whose consequences outlive the sandbox.

from langgraph.types import interrupt, Command

def approval(state: State):
    call = state["messages"][-1].tool_calls[0]
    if not needs_review(call["args"]["command"]):
        return Command(goto="tools")
    ok = interrupt({"question": "Run this?", "command": call["args"]["command"]})
    return Command(goto="tools" if ok else "model")

# later, from your UI:
app.invoke(Command(resume=True), cfg)

The interesting consequence is what you don't gate. Once execution is inside a VM you own and can destroy, the vast majority of what the model wants to do is self-contained: deleting a file, filling the disk, forking a hundred processes, writing itself a script that deletes the script. All of it dies with the sandbox. So the review list shrinks to actions that reach outside the box — a POST to a production API, a write to a shared bucket, an email, a payment, a git push. That's a list a human can actually review, rather than an approval prompt on every command that trains your users to click yes without reading. Fewer, better gates is a real security outcome and not just a UX one.

An interrupt can outlive the sandbox. That's the checkpointing problem wearing a different hat: the thread paused on Friday afternoon resumes on Monday morning against an id that expired hours later. Route the resume path through the same attach-or-create helper, and don't be tempted to give the sandbox a week-long TTL to dodge the issue — you'd be paying for idle machines to avoid writing eight lines of defensive code.

The short version

  1. Execution goes to a VM boundary, not exec(), not subprocess, not a container next to your API.
  2. The tool returns a string with the exit code, the stderr, and middle-truncated output — and never raises for code the model can fix.
  3. One sandbox per thread for anything conversational; per-call ephemeral only when carry-over would be a bug.
  4. The sandbox id lives in graph state, resolved from the injected thread_id, never passed by the model.
  5. Every read of that id goes through attach-or-create: verify, recreate on miss, re-seed, and tell the model the environment was rebuilt.
  6. A TTL at creation as the backstop, an explicit kill when the conversation ends as the mechanism.
  7. Stream long commands out through custom stream mode, and put a hard per-call timeout on every single one.
  8. Interrupt on side effects that escape the sandbox; let everything inside it run without asking.

Almost none of this is about LangGraph, which is the point. The tool body is the same function you'd write for LangChain or CrewAI. What LangGraph adds is durable state and long-lived threads, and those turn the sandbox lifecycle from an implementation detail into the thing most likely to page you. Write the defensive re-attach first and the rest of it is thirty lines.

Frequently asked questions

Should the sandbox id live in LangGraph state or in a separate store?

Graph state, in almost every case. It is already persisted by the checkpointer, already keyed by thread, and already visible when you inspect a thread while debugging, so you get durability and observability for free. The tempting alternative — a module-level dictionary keyed by thread_id — works perfectly on one process and breaks the moment you run two web workers, because half the requests land on a process that has never seen that conversation and quietly create a duplicate sandbox. A separate store is only worth it if you need the mapping outside the graph entirely, such as for a billing or cleanup job.

What happens if a checkpointed run resumes after the sandbox has expired?

The id in state points at nothing, and a naive tool raises a not-found error out of the middle of a graph run. This is the single most common bug in LangGraph sandbox integrations because it only appears once a thread sits idle longer than the TTL, which never happens in a test. Treat every stored id as a cache entry: fetch the sandbox, check it is actually running, and create a fresh one on any miss. Then re-seed whatever the conversation depends on, and say in the tool output that the environment was rebuilt, so the model does not keep referencing files that no longer exist.

Is one sandbox per tool call too expensive?

It depends entirely on your provider's startup time, and that number should drive the design rather than the other way round. On PandaStack a create is a snapshot restore at 179ms p50 and roughly 203ms p99, which is fast enough that per-call creation hides inside the model's own latency and stops being a design constraint. If your sandbox takes ten seconds to become usable, per-call is off the table and you will be pushed toward a warm pool — which is where cross-tenant state leaks come from. The honest answer is to measure your provider's cold path first, then pick per-thread or per-call on the merits.

How do I stop a model-written infinite loop from hanging my graph?

Pass an explicit timeout on every execution and enforce it at the sandbox rather than in your own process. Sixty seconds suits an interactive tool; a build or test-suite tool can justify ten minutes, but it still needs a ceiling. When the timeout fires, return a normal tool result saying the command was killed for running too long rather than raising, because the model will usually respond by writing something cheaper. The reason the boundary matters is enforceability: killing a microVM stops the work unconditionally, while an in-process timer against code running in your own interpreter is a suggestion the runaway code can ignore.

When should a LangGraph agent interrupt before running code?

When the action reaches outside the sandbox. Anything self-contained — deleting files, filling the disk, spawning processes, running a destructive script on its own scratch data — dies with the VM, so gating it buys nothing and trains users to approve without reading. Reserve interrupts for effects that survive the sandbox: writes to production APIs or shared storage, sending email, moving money, pushing to a repository. That keeps the approval list short enough that a human genuinely reviews each one. Remember that an interrupt can be resumed days later, so route the resume path through the same attach-or-create helper as everything else.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.