Persistent sandbox sessions across agent turns
Run an LLM agent loop with a persistent sandbox session — keep a code-interpreter kernel warm across turns, resume its memory after a restart, and fork it for parallel rollouts.
An agent that computes needs somewhere durable to compute. The naive loop spins up a fresh sandbox per turn and loses every variable, import, and loaded DataFrame between calls — the model re-parses the same CSV on turn 4 that it parsed on turn 1. Worse, when your orchestrator process restarts (a deploy, a crash, a queue worker recycling), the in-memory kernel state is gone for good.
This recipe is the PandaStack-internals backbone behind the LangGraph and data-analyst recipes — no external framework, just three primitives that keep a code interpreter session alive: a long-lived TTL sandbox (state across turns in one process), snapshot/restore (state across process restarts), and fork() (branch the warmed state for parallel rollouts in ~400ms).
Setup
export PANDASTACK_API_KEY=pds_...
pip install pandastackThe SDK reads PANDASTACK_API_KEY from the environment — never hardcode it. The code-interpreter template bakes IPython, matplotlib, plotly, kaleido, and pandas; the kernel captures charts and DataFrames as rich results automatically, so nothing is installed inside the sandbox.
1. State across turns: one sandbox, one kernel
The first win is in-process persistence. Create a single sandbox with a generous ttl_seconds, open one CodeContext — a Jupyter-style kernel — and reuse it for every turn of the loop. Variables, imports, and intermediate results carry over from one run_code to the next, exactly like a notebook.
from pandastack import Sandbox
# One long-lived sandbox for the whole conversation. ttl_seconds keeps it from
# being reaped between turns; bump it if turns are far apart.
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=3600)
ctx = sandbox.create_code_context() # persistent kernel; state survives run_code
# Turn 1: load data once, define helpers. This lives in the kernel.
ctx.run_code("import pandas as pd; df = pd.DataFrame({'a': [1, 2, 3]})")
# Turn 2 (later in the loop): the df is still there — no reload, no re-parse.
ex = ctx.run_code("total = int(df['a'].sum()); total")
print(ex.text) # -> "6"import { Sandbox } from "@pandastack/sdk";
const sandbox = await Sandbox.create({ template: "code-interpreter", ttlSeconds: 3600 });
const ctx = await sandbox.createCodeContext(); // persistent kernel
await ctx.runCode("import pandas as pd; df = pd.DataFrame({'a': [1, 2, 3]})");
const ex = await ctx.runCode("total = int(df['a'].sum()); total");
console.log(ex.text); // -> "6"Each run_code returns an Execution — the thing your tool hands back to the model:
| Field | What you get back |
|---|---|
ex.text | last-expression text repr (what most turns return to the model) |
ex.stdout | captured stdout, if the cell printed |
ex.png | first chart as a base64 PNG — the chart object, no savefig |
ex.results[0].html | a DataFrame rendered as an HTML <table> |
ex.error | formatted traceback if the cell raised, so the model can self-correct |
This is all an in-process agent loop needs: feed model-authored code into ctx.run_code, return ex.text (or ex.error) as the tool result, repeat. The next turn sees everything the previous turn defined.
2. State across restarts: snapshot and resume
A TTL sandbox survives between turns, but not between processes. When your orchestrator restarts, the running VM may still exist — but you've lost the handle, and you certainly can't recover an in-memory kernel that has since been reaped. The fix is a snapshot: capture the full VM (memory + running processes + disk) to durable storage, persist the snapshot id, and restore it on the next turn.
# --- End of a session, or right before a planned shutdown ---
snap_id = sandbox.snapshot() # str: capture vm.mem + vm.state + rootfs to storage
save_to_db(conversation_id, snap_id) # persist the id wherever you keep session state
sandbox.kill() # free the VM; the snapshot outlives it// snapshot() returns a SnapshotInfo object in TS — read .id
const snap = await sandbox.snapshot();
const snapId = snap.id;
await saveToDb(conversationId, snapId);
await sandbox.kill();Later — a new process, a new request, hours apart — restore with from_snapshot. The VM comes back with RAM contents intact: the kernel process, its imports, and df are all live again.
# --- New process / next turn: resume the exact memory + disk state ---
snap_id = load_from_db(conversation_id)
sandbox = Sandbox.create(from_snapshot=snap_id) # template/cpu/mem inherited from snapshot
# The restored VM has a NEW id, so the old CodeContext handle is NOT portable.
# Re-open a context on the restored sandbox — the KERNEL state behind it survived.
ctx = sandbox.create_code_context()
print(ctx.run_code("print(total)").text) # -> "6": in-kernel state came backconst snapId = await loadFromDb(conversationId);
const sandbox = await Sandbox.create({ fromSnapshot: snapId });
const ctx = await sandbox.createCodeContext(); // new handle; kernel state survived
console.log((await ctx.runCode("print(total)")).text); // -> "6"Two things to get right, both consequences of how snapshot-restore works:
| Behavior | Why |
|---|---|
| The restored sandbox has a new id | Restore boots a fresh VM from the snapshot's memory image; you re-call create_code_context() (the kernel process survives in RAM, but the REPL session handle is bound to the old sandbox id). |
template, cpu, memory_mb come from the snapshot, not your request | Firecracker can't resize a VM at restore. from_snapshot ignores the request template; cpu/memory_mb are deprecated and ignored in both SDKs regardless. Size is baked in. |
This is the difference between "the agent remembers within a turn" and "the agent's working memory is genuinely durable." You can checkpoint after every turn, after every N turns, or only at shutdown — the snapshot is a point-in-time fork of the entire VM, so the resume is exact.
3. Branch the warmed state: fork for parallel rollouts
Sometimes you don't want to resume one session — you want to branch it. Tree-of-thought search, best-of-N sampling, and "try three approaches from this state" all want N independent copies of the same warmed kernel. That's fork(): a copy-on-write child off a live sandbox, ~400ms same-host because it reflinks the rootfs and shares memory pages until they diverge.
# warm: a sandbox whose kernel already loaded the dataset and ran setup
warm = Sandbox.create(template="code-interpreter", ttl_seconds=3600)
ctx = warm.create_code_context()
ctx.run_code("import pandas as pd; df = pd.read_csv('/data/big.csv')") # the expensive part
# Branch the warmed state into N independent rollouts. Each child has its own id,
# its own kernel memory, and starts from the parent's exact state. The parent is untouched.
branches = warm.fork_tree(3, metadata={"search": "tree-of-thought"})
for i, sb in enumerate(branches):
bctx = sb.create_code_context() # new context handle per child (new sandbox id)
# Each branch explores a different hypothesis against the SAME loaded df —
# no re-download, no re-parse. Pages are shared CoW until a branch writes.
out = bctx.run_code(f"df.sample(frac=0.5, random_state={i})['a'].mean()")
print(i, out.text)
# Score the rollouts, keep the winner, kill the rest.
for sb in branches:
sb.kill()const warm = await Sandbox.create({ template: "code-interpreter", ttlSeconds: 3600 });
const ctx = await warm.createCodeContext();
await ctx.runCode("import pandas as pd; df = pd.read_csv('/data/big.csv')");
const branches = await warm.forkTree({ count: 3, metadata: { strategy: "tree-of-thought" } });
for (const sb of branches) {
const bctx = await sb.createCodeContext();
console.log((await bctx.runCode("df['a'].mean()")).text);
}A single fork() works the same way for the two-branch case:
branch = warm.fork(metadata={"branch": "experiment"})Forks default to the parent's agent host so the reflink stays local; a cross-host fork (e.g. if the parent's host is full) downloads from GCS and runs 1.2–3.5s instead. Either way the parent keeps running — fork never disturbs the source sandbox.
Putting it together: a durable loop
The shape of a restart-safe agent loop: resume if you have a checkpoint, otherwise start fresh; run the turn; checkpoint.
from pandastack import Sandbox
def session(conversation_id: str, user_code: str) -> str:
snap_id = load_from_db(conversation_id) # None on first turn
if snap_id:
sandbox = Sandbox.create(from_snapshot=snap_id, ttl_seconds=3600) # resume memory + disk
else:
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=3600)
ctx = sandbox.create_code_context() # re-open on the (possibly new) id
ex = ctx.run_code(user_code) # the agent's tool call for this turn
save_to_db(conversation_id, sandbox.snapshot()) # checkpoint for the next process
sandbox.kill() # snapshot outlives the VM
return ex.error or ex.textFor a hot, long-running conversation you'd keep the sandbox alive across turns (section 1) and only snapshot at boundaries; for a queue-driven or serverless loop where each turn is a cold invocation, snapshot-and-kill every turn (above) is the durable default.
Gotchas
snapshot()return type differs by SDK. Python returns the snapshot id as astr; TypeScript returns aSnapshotInfoobject — read.id. Persist the id, not the object.- A restored sandbox has a new id, and the old
CodeContexthandle is not reusable. Re-callcreate_code_context()afterfrom_snapshot. The kernel memory survives; the REST session handle does not. - You cannot resize on restore.
from_snapshotinheritstemplate/cpu/memory_mbfrom the snapshot; the request values are ignored. Bake the size you need into the template. - Re-baking the template invalidates derived snapshots. A stored snapshot id is tied to the template generation it was taken from — re-bake the
code-interpretertemplate and old snapshot ids stop restoring. Re-snapshot after a re-bake. persistent=Truechanges cleanup. Apersistentsandbox isn't auto-reaped by TTL and (as a context manager) isn't auto-killed on exit — you own its lifecycle. Use it when you want the VM itself to outlive the process instead of round-tripping through a snapshot.
Next steps
- Code interpreter — the pillar guide: code contexts, rich results, one-shot exec, and file uploads.
- Snapshots and forks — the lifecycle primitives: snapshot, fork, hibernate, wake.
- Run code from a LangGraph agent — wire this persistent kernel into a ReAct graph runtime.
- Build a data-analyst agent — fork a loaded dataset to explore analyses in parallel.
Build an MCP code execution server on Firecracker
Build your own MCP code execution server in Python whose run_python tool runs every snippet in a Firecracker-isolated PandaStack sandbox.
Supabase auth
Configure Supabase Auth for a self-hosted PandaStack deployment with multiple users, organizations, or a network-exposed dashboard.