PandaStack
Migration

Migrate from Modal

Mapping Modal Sandboxes to PandaStack — create, exec, files, tunnels, and images — and what changes when you move.

Modal Sandboxes and PandaStack sandboxes cover the same job: spin up an isolated environment, run commands in it, read the output, tear it down. The mental model transfers directly; the API shapes differ in a few specific ways, listed below.

Setup

ModalPandaStack
Packagemodal (Python)pandastack (Python), @pandastack/sdk (TS)
Authmodal token newPANDASTACK_API_KEY env var, or pandastack auth login
App scopingSandboxes belong to a modal.AppNo app object — sandboxes are top-level in your workspace
API baseModal's APIhttps://api.pandastack.ai (Authorization: Bearer $PANDASTACK_API_KEY)
pip install pandastack
npm install -g @pandastack/sdk     # CLI ("pandastack" command)
export PANDASTACK_API_KEY=pds_...

Cheat sheet

ModalPandaStackNotes
modal.Sandbox.create(app=app, image=img)Sandbox.create(template="...")No app object; environment comes from the template.
modal.Image.debian_slim().pip_install(...)A Dockerfile + pandastack template buildEnvironment defined in a Dockerfile, built server-side, snapshot-baked.
sb.exec("python", "-c", "...") → process handlesandbox.exec("python3 -c '...'") → completed resultArgv list vs. one shell string; blocking result vs. handle.
p.stdout.read() after p.wait()result.stdoutexec returns stdout/stderr/exit_code directly.
for line in p.stdout:sandbox.exec_stream(cmd, on_stdout=...)Streaming via callbacks (SSE underneath).
sb.open("/data.txt", "w")sandbox.filesystem.write("/data.txt", ...)Plus read, listdir, stat, exists, upload, download.
sb.terminate()sandbox.kill()
timeout= on createttl_seconds= on create, set_ttl() later
sb.poll()Sandbox.get(id).statusrunning, paused, etc.
Tunnels / exposed portssandbox.preview_url(port)Stable https://<port>-<id>.<suffix> URL, no token dance.
sb.snapshot_filesystem()sandbox.snapshot()PandaStack snapshots capture memory + disk, not just the filesystem.
sandbox.hibernate() / wake()Park a sandbox (RAM billing stops), auto-wake on next request.
sandbox.fork() / fork_tree(n)Live copy-on-write clones, 400–750 ms same-host.

Creating a sandbox

# Modal
import modal
app = modal.App.lookup("my-agent", create_if_missing=True)
sb = modal.Sandbox.create(app=app)

# PandaStack — no app object, just a template
from pandastack import Sandbox
sandbox = Sandbox.create(template="code-interpreter", ttl_seconds=600)
import { Sandbox } from "@pandastack/sdk";
const sandbox = await Sandbox.create({ template: "code-interpreter", ttlSeconds: 600 });
pandastack sandbox create --template code-interpreter --ttl 600

Creates restore a pre-baked snapshot at ~179 ms p50. Sandboxes are Firecracker microVMs with their own kernel, so anything that runs on Ubuntu runs inside — including Docker-hostile things like mount or database servers.

Running commands — the argv-vs-string change

This is the main call-site rewrite. Modal's exec takes an argv list and returns a process handle you wait() on; PandaStack's exec takes one shell string, runs it under sh -c, and returns the completed result.

# Modal
p = sb.exec("python", "-c", "print(1 + 1)")
p.wait()
print(p.stdout.read(), p.returncode)

# PandaStack
result = sandbox.exec("python3 -c 'print(1 + 1)'", timeout_seconds=30)
print(result.stdout, result.exit_code)

Because it's a shell string, pipes and env vars just work — no handle plumbing:

sandbox.exec("cd /work && KEY=$SECRET python3 agent.py | tail -20")

For long-running commands where you consumed p.stdout incrementally, use the streaming variant:

exit_code = sandbox.exec_stream(
    "pip install -r requirements.txt",
    on_stdout=lambda chunk: print(chunk, end=""),
)
const result = await sandbox.exec("python3 -c 'print(1 + 1)'", { timeoutSeconds: 30 });
console.log(result.stdout, result.exitCode);

const exitCode = await sandbox.execStream("pip install -r requirements.txt", {
  onStdout: (chunk) => process.stdout.write(chunk),
});
pandastack sandbox exec <sandbox-id> --timeout 30 -- "python3 -c 'print(1 + 1)'"

There is no detached process handle: to run something in the background, launch it the Unix way (setsid cmd > /var/log/out.log 2>&1 &) and tail the log with sandbox.logs(follow=True) or a later exec.

Files

Modal's file-handle style (sb.open) becomes explicit read/write calls:

# Modal
with sb.open("/data.txt", "w") as f:
    f.write("hello")

# PandaStack
sandbox.filesystem.write("/data.txt", "hello")          # str or bytes
content = sandbox.filesystem.read("/data.txt")          # bytes (Python) / string (TS)
entries = sandbox.filesystem.listdir("/root")           # TS: filesystem.list()
sandbox.filesystem.upload("./local.csv", "/root/data.csv")
sandbox.filesystem.download("/root/out.json", "./out.json")

CLI equivalents: pandastack fs read|write|upload|download. See Filesystem.

Exposing ports

Where Modal gives you tunnels, every PandaStack sandbox port is reachable at a stable preview URL for the sandbox's lifetime — the sandbox UUID is the credential:

sandbox.exec("setsid python3 -m http.server 8000 >/tmp/http.log 2>&1 &")
url = sandbox.preview_url(8000)   # https://8000-<sandbox-id>.pandastack.ai

CLI: pandastack sandbox preview-url <id> --port 8000. See Preview URLs.

Lifecycle: terminate, snapshots, and parking state

# Modal
sb.terminate()

# PandaStack
sandbox.kill()                      # same thing

# Where Modal has snapshot_filesystem(), PandaStack snapshots memory + disk:
snap_id = sandbox.snapshot()
restored = Sandbox.create(template="code-interpreter", from_snapshot=snap_id)

# And two things with no direct Modal equivalent:
sandbox.hibernate()                 # park it: RAM billing stops, state kept
sandbox.wake()                      # or just send any request — it auto-wakes
child = sandbox.fork()              # live CoW clone in 400–750 ms (same host)

Hibernate/wake is the idiomatic replacement for "terminate and rebuild the environment next time": the sandbox keeps its full memory and disk state while costing nothing in RAM, and wakes on the next request. Forks make best-of-N agent exploration cheap — see Snapshots and forks.

Images → templates

Modal defines environments in Python (modal.Image.debian_slim().pip_install(...)); PandaStack defines them in a Dockerfile and bakes a boot snapshot server-side:

pandastack template build --name my-agent-env -f Dockerfile
sandbox = Sandbox.create(template="my-agent-env")

First-party templates: base, code-interpreter, agent, browser, postgres-16. RAM is baked into the template snapshot rather than passed per-create. See Templates.

What's actually different

PandaStack adds: managed PostgreSQL 16 (create in 30–90 s, PITR clones, failover — Databases), git-driven app hosting with blue-green deploys and ~1–2 s scale-to-zero wake (Apps), serverless functions + cron (Functions), live sandbox forking, and full self-hosting — the platform is open source and runs on your own cloud account or on-prem KVM hosts (Self-host). Pricing is one rate card: $0.054/vCPU-hr for actively burned CPU + $0.0162/GiB-hr for committed RAM, no per-request charges.

Modal has things PandaStack doesn't: GPUs, environments defined in Python code rather than Dockerfiles, and sandboxes that plug into Modal's broader serverless platform — its functions, volumes, and deployment tooling form a tightly integrated ecosystem that PandaStack does not replicate. If your sandboxes exist to sit next to GPU inference jobs on the same platform, Modal remains the better fit for that piece.

Migration checklist

  1. pip install pandastack; set PANDASTACK_API_KEY.
  2. Translate your modal.Image chain into a Dockerfile; pandastack template build --name ... -f Dockerfile.
  3. Drop the modal.App scaffolding; Sandbox.create(template=...) directly.
  4. Rewrite exec(argv...) + handle plumbing into shell strings with exec(cmd) (or exec_stream for incremental output).
  5. sb.open(...) file handles → sandbox.filesystem.read/write.
  6. Tunnels → sandbox.preview_url(port).
  7. terminate()kill(); consider hibernate() where you were tearing down only to rebuild later.

On this page