PandaStack

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 pandastack

The 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:

FieldWhat you get back
ex.textlast-expression text repr (what most turns return to the model)
ex.stdoutcaptured stdout, if the cell printed
ex.pngfirst chart as a base64 PNG — the chart object, no savefig
ex.results[0].htmla DataFrame rendered as an HTML <table>
ex.errorformatted 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 back
const 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:

BehaviorWhy
The restored sandbox has a new idRestore 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 requestFirecracker 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.text

For 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 a str; TypeScript returns a SnapshotInfo object — read .id. Persist the id, not the object.
  • A restored sandbox has a new id, and the old CodeContext handle is not reusable. Re-call create_code_context() after from_snapshot. The kernel memory survives; the REST session handle does not.
  • You cannot resize on restore. from_snapshot inherits template/cpu/memory_mb from 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-interpreter template and old snapshot ids stop restoring. Re-snapshot after a re-bake.
  • persistent=True changes cleanup. A persistent sandbox 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

On this page