all posts

The Best Sandbox APIs for Ruby and Rails AI Agents in 2026

Ajay Kumar··13 min read

Somewhere in your Rails app there is a method that takes a string the model wrote and does something with it. Maybe it is a data-cleaning script. Maybe it is a migration. Maybe it is a rake task the agent decided to invent. And somewhere near that method there is a line you have not looked at closely enough, which is either eval, or system, or a Sidekiq job that shells out, and it runs inside the same process that holds your ActiveRecord connection pool and your Rails.application.credentials.

I am Ajay. I build PandaStack, which is one of the entries below, so read this as a vendor's roundup and discount accordingly. In exchange I will write the post nobody writes for Ruby, and open it with the single most useful finding in the whole comparison: essentially no code-execution sandbox vendor ships a first-party Ruby SDK. Not mine either. PandaStack's official SDKs are Python and TypeScript — pandastack on PyPI, @pandastack/sdk on npm — and Ruby teams talk to the REST API. I would rather say that in paragraph two than let you discover it after you have written the integration ticket.

Which is actually good news, because it changes the question you are asking. A Python team asks how good the SDK is. You are asking how good the HTTP surface is, and how much of a client you are about to write. That is a cheaper question to answer — an afternoon with curl settles most of it — and Ruby is unusually well set up for the answer. Net::HTTP is in the standard library, JSON is in the standard library, and if you would rather not write connection handling by hand, Faraday is already in your Gemfile because six of your other gems depend on it.

The rest of this post is the criteria I would use if I were the Ruby person on the team, an honest read of the field through that lens, and about two hundred lines of real Ruby: a stdlib-only client, the Faraday version, an SSE reader, and a Rails-flavoured example where an agent rehearses a generated migration inside a disposable microVM before a human ever sees the diff.

Disclosure and ground rules. PandaStack is mine. Specific numbers — create latency, fork times — appear only for my own system, where I can point at the code that produces them. Every other platform is described qualitatively, in terms of what it is positioned for and what tradeoff it makes. One rule I am holding especially hard: I will not assert whether any given vendor does or does not ship a Ruby client today, because that is exactly the fact most likely to change between my writing this and your reading it. Check their docs, their GitHub organisation and rubygems.org yourself, and write down the date you checked.

First, the bad news about sandboxing inside Ruby

Ruby people reach for a Ruby answer first, and Ruby has an unusually seductive set of wrong answers because the language makes running a string so pleasant. Here are the four you will consider, in the order people consider them.

eval is not a sandbox, and it is not close

The first idea is always eval, or its slightly more respectable cousins instance_eval and Kernel#eval with a binding. The reasoning goes: I will pass a clean binding, so the code cannot see my locals. This misunderstands what a binding is. A binding scopes local variables. It does nothing about constants, and in Ruby every interesting thing is a constant reachable from Object.

Model-generated code inside eval can reach ActiveRecord::Base and therefore your database, Rails.application.credentials and therefore every secret you have, ENV and therefore your instance metadata token, File and therefore your entire filesystem, and Kernel#system and therefore a shell. It can reopen String and redefine a method your web request handler is about to call, in the same process, in the same thread pool. It can call ObjectSpace.each_object and walk to any live object in the VM, including one holding a decrypted credential. There is no argument to eval that closes any of this.

The pattern that actually gets shipped is worse than the naive one, because it looks careful. Someone writes a denylist: reject the string if it contains system, or backticks, or File. Ruby has send. Ruby has const_get. Ruby has method_missing. Ruby lets you build any identifier out of string arithmetic at runtime. A denylist against a language with reflection this good is a speed bump described in the PR as a mitigation.

$SAFE was the closest thing Ruby had, and it is gone

For a long time the answer to this question was $SAFE, Ruby's taint-tracking mode, which marked externally-derived strings as tainted and refused to let them reach dangerous operations at higher safe levels. It was never a complete boundary — it was a set of checks scattered through the interpreter, and it leaked — but it was something.

It is not something any more. Taint checking was deprecated in Ruby 2.7 and removed in Ruby 3.0; $SAFE became a normal global variable with no interpreter behaviour attached. If you find a Stack Overflow answer recommending $SAFE = 4, you have found an answer written for an interpreter that has not existed for years. Ruby 3.x has no supported in-process mechanism for confining untrusted code. That is not a criticism of Ruby — it is the same position Java landed in when the SecurityManager was disabled, and for the same reason: per-call-site permission checks in a reflective language are one missed check away from irrelevant.

fork and Process.spawn are process isolation, not security isolation

So you shell out. Process.spawn with a timeout, or fork with an at_exit, or Open3.capture3 into a Tempfile directory. This is real progress for reliability: a runaway allocation in the child no longer takes down your Puma worker, and you get a clean way to enforce a wall-clock timeout. It is approximately zero progress on security.

That child is your process tree. It runs as your deploy user, on your kernel, with your working directory, your environment, your outbound network, and — this is the Rails-specific sting — your ~/.gem and your bundle config, which on a lot of deploys contains a credential for a private gem host. Every ENV variable your Puma process inherited is one getenv away. Your cloud instance metadata endpoint is one HTTP request away, and it will happily hand over a role credential to anything that can make a request from the box.

The Rails version of this failure is particularly nasty because of preloading. Under Puma in clustered mode with preload_app!, the master process has already booted the whole application: every initializer has run, the credentials file has been decrypted into memory, the database connection pool exists, and any client library that cached an API token at boot is holding it. A fork inherits that memory image. The child does not need to read a secret off disk; it already has one in its heap.

A container helps, and is still a shared kernel

So you put it in a container, which is a genuine improvement and worth doing. Namespaces give the code its own filesystem and process view. Cgroups give you real CPU and memory limits instead of hopeful ones. A seccomp profile cuts the syscall table down. Dropping capabilities removes most of the interesting ones. Do all of it — it is cheap and it is strictly better than the alternatives above.

But be precise about what you have bought. One kernel, shared between your host and every container on it, with the whole Linux syscall interface as the attack surface for code that no human reviewed. For first-party code you wrote and reviewed, that is a perfectly reasonable posture and I would not spend a sprint changing it. For code an LLM generated — possibly while under the influence of a prompt injection sitting in a GitHub issue your agent read forty seconds ago — a hardware-virtualized boundary is the right default, because then each execution gets its own guest kernel and the exposed surface is a small, heavily audited virtual machine monitor rather than all of Linux.

The Ruby-specific costs nobody budgets for

Everything above is the security argument, and it is the same argument in every language. What is genuinely different about Ruby is the economics of the loop. Three things will dominate your latency and your bill, and none of them appear in a vendor's benchmark page.

bundle install is a build, and it is slow

In a Python sandbox, pip install of a pure-Python package is a download and an unzip. In a Ruby sandbox, bundle install for a realistic Gemfile compiles C. nokogiri, pg, bcrypt, ffi, sassc, grpc, sqlite3 — these ship native extensions, and unless a precompiled platform gem exists for your exact Ruby ABI and architecture, the gem's extconf.rb runs and gcc goes to work. On a real Rails Gemfile with 150 gems, a cold bundle install measured in minutes is normal, not pathological.

This has two consequences. First, if your agent creates a fresh sandbox per tool call and runs bundle install each time, setup is not part of your loop cost — setup is your loop cost, and the model's actual work is a rounding error against it. Second, and this is the part people miss: extconf.rb is arbitrary Ruby that runs at install time, from source you did not write, with a compiler. If your Gemfile came from a model, or the model added a gem, you are executing untrusted code before you have run any untrusted code. I wrote about that specific hole separately in the post on running bundle install on untrusted Ruby, and the short version is that it belongs behind the same boundary as everything else.

The design conclusion is the same either way: bake your gems into the image, or snapshot the sandbox after the install and start every subsequent run from that snapshot. Do not pay for the install more than once. This is the single biggest lever in a Ruby agent loop and most teams find it after they get the first invoice.

Rails boot time is the other half of the latency

The second cost is that Rails takes seconds to boot. bin/rails runner on a mid-sized application is commonly two to ten seconds before your code's first line executes, because it is requiring several hundred files, running every initializer, connecting to the database and eager-loading in production mode. If your agent's tool is a bin/rails runner call, you have signed up for that on every single tool call.

Two ways out, and they compose. Keep the sandbox alive across the agent's turns so the filesystem, the bundle and any warm caches persist, which turns your create cost into a once-per-run cost instead of a once-per-turn cost. And batch: have the model write one script that does five things rather than making five runner calls, or start a long-lived process in the sandbox and talk to it. The worst pattern, which I see constantly, is one create plus one bundle plus one rails runner per model turn — that is a thirty-second floor on a loop where the model itself responds in two.

What 'run the tests' actually means in Ruby

The third thing is that a Ruby agent's most common instruction is 'run the tests and fix what broke', and that is a much richer operation than it sounds. It means bundle exec rspec, which means a loaded Rails environment, which means a database that has been prepared and migrated, which means db:test:prepare, which means a Postgres or MySQL reachable from the sandbox. It probably means a Redis for Sidekiq. It may mean a headless Chrome for system specs.

So when you evaluate a sandbox platform, the question is not 'can it run Ruby'. Every one of them can run Ruby. The question is whether you can express a small environment — app plus database plus Redis — cheaply and disposably, and whether the platform gives you a way to keep that environment warm between turns. That reframes the shortlist considerably, and it is why I weight statefulness and snapshotting much more heavily for Ruby than I would for a stateless Python code-interpreter workload.

A specific thing to check before you commit: whether the platform's default image has Ruby at all, and which version. A lot of agent-sandbox templates are built around Python and Node because that is what the code-interpreter use case needed. PandaStack's base template pre-warms Node, Python, Go and Bun through mise and does not pre-warm Ruby — mise can install it, but that is a compile you do not want in your hot path, so the honest answer for a Ruby shop on my platform is to bake a template with your Ruby and your gems already in it. Ask every vendor the same question and do not accept 'you can install it' as a yes.

The criteria a Ruby team should actually use

Ten things, ordered roughly by how much time they will cost you if you get them wrong. The first three are Ruby-shaped; the rest apply to everybody but land differently when you are the one writing the client.

  1. A documented REST surface, ideally with a machine-readable spec. This is the whole ballgame for you, because there is no SDK to hide behind. If there is a published OpenAPI 3.x document you can generate a client or at least read the exact field names and nullability. If the API is documented only as prose examples in Python, you are transcribing curl commands by hand and discovering undocumented fields in production, on a Friday.
  2. Blocking exec that returns a structured result. A synchronous POST that returns stdout, stderr and an exit code as three JSON fields maps onto a Struct in one line and onto an agent tool in three. An API that only streams, or that makes you poll a job resource for a two-second command, adds a state machine to every call site and a background job to your Rails app.
  3. Statefulness you control, because of bundle install. Can a sandbox outlive one call? For how long? What happens when it idles because your agent is waiting ninety seconds for a model response — does it get reaped, does it get billed, or does it get suspended? A platform that bills idle time punishes exactly the pattern Ruby needs, and a platform that reaps aggressively makes you re-bundle. Ask about both.
  4. Snapshot or fork, or an equivalent way to freeze a warm state. This is the Ruby superpower if a platform has it: install the gems once, snapshot, and start every subsequent run from the post-install state. If the platform can fork a running machine, you can also run three candidate patches from one warm parent in parallel and keep the one whose specs pass.
  5. Filesystem endpoints that are first-class, not shell tricks. You will be moving a source tree in and a JUnit XML or an RSpec JSON report out. Base64 through a shell command works right up until the model emits a quote character, and it will emit a quote character. Check the size ceiling before you need to move a 40MB report or a precompiled asset bundle.
  6. Per-sandbox network egress policy. A perfectly isolated microVM with unrestricted internet still exfiltrates everything you put in it, and 'the agent ran a gem's postinstall hook' is exactly the scenario where that matters. Look for policy you can set per sandbox at create time, not one account-wide firewall rule owned by whoever is on platform this quarter.
  7. Startup latency, measured on your template, not the vendor's hello-world. This determines your whole architecture: whether you can afford fresh-per-call or must manage sessions. Low hundreds of milliseconds means fresh-per-call is a real option. Tens of seconds means you are writing session management, idle reaping and orphan cleanup, and you now maintain that forever.
  8. Server-enforced timeouts and TTLs. A client-side Timeout.timeout that returns control to your Puma thread while the guest merrily keeps running the model's infinite loop is not a timeout, it is a leak with good manners. You want a per-exec timeout enforced inside the guest, and a TTL on the sandbox as the backstop for when your dyno restarts between create and cleanup.
  9. Isolation boundary, stated plainly. Sandbox is not a regulated term. Make the vendor say whether your code gets its own guest kernel, a user-space kernel, or namespaces on a shared host kernel. All three are legitimate products with real users; only one of them is a hardware boundary, and you need to know which one you are describing to your security reviewer.
  10. Self-host and licence. Sometimes a hard requirement — data residency, an air-gapped customer, an auditor with a checklist — and sometimes a preference that costs you an engineer. Check what is actually open source: the client, the runtime, or the control plane. These are very different answers wearing the same badge.

The Ruby-specific one: which HTTP client, and does it survive Puma

Since you are writing the client, decide early how it talks. There are three sane answers and one trap.

Net::HTTP is in the standard library and is genuinely fine. It does JSON, it does streaming response bodies, it does per-connection open and read timeouts. Its weakness is that it has no connection pool: Net::HTTP.start opens a socket, does the request and closes it, so under a 16-thread Puma process hammering an agent loop you are paying a TLS handshake per call. For an agent that makes a handful of calls per turn that is noise. For a high-throughput tool loop it is not.

Faraday is the pragmatic answer for a Rails app, mostly because it is already installed. Since Faraday 2 the retry middleware and the adapters live in separate gems — faraday-retry, faraday-net_http_persistent — which trips people up, and the persistent adapter is the one you want, because it is what gives you the pooling Net::HTTP lacks. HTTP.rb (the http gem) is the third good answer if you like its API, with http-persistent connection reuse available too.

The trap is retry middleware on non-idempotent calls. Retrying a create on a 503 is correct and you should. Blind-retrying POST /exec on a read timeout is how you run the model's database migration twice. Configure retries per-method, or better, wrap only the calls you know are safe and make everything else fail loudly.

One more Ruby-shaped concern: your agent's tool call is a long, blocking HTTP request, and in a Rails app it is probably happening inside a Sidekiq job or an ActiveJob worker. That is the right place for it. Do not do it in a controller action: a two-minute rspec run holding a Puma thread and a database connection from the pool is a self-inflicted outage on a busy box. Enqueue it, stream progress to the client over ActionCable or a polled status endpoint, and set the job's timeout longer than the sandbox's so the server-side timeout is the one that fires first.

The field, through a Ruby lens

Grouped by the job each is genuinely positioned for, not ranked, because ranking them requires pretending they are the same product. Every entry carries the same caveat: verify the language support, isolation model, limits and pricing against that vendor's own current documentation before you commit, and note the date you read them. I am describing positioning, not reciting a feature matrix I cannot keep current.

PandaStack (mine — read accordingly)

Firecracker microVMs with a small REST API in front of them. Every create is a snapshot restore rather than a boot, with no warm pool of idle machines behind it, which is what gets create to 179ms at p50 and around 203ms at p99; the only slow path is the very first spawn of a brand-new template, which cold-boots in roughly three seconds to bake its snapshot. Forking a running sandbox is copy-on-write on both memory and disk, at 400 to 750 milliseconds on the same host. Idle sandboxes scale to zero, so a machine you are not using does not bill. There is managed Postgres alongside it with branching and point-in-time restore, git-driven app hosting, and serverless functions with cron, which matters here only because a Rails agent usually needs a database next to the sandbox rather than a sandbox alone.

The Ruby story, stated plainly: there is no Ruby SDK. Python and TypeScript are the official SDKs and Ruby teams use the REST API. The surface is small enough that this is a genuinely small task — POST /v1/sandboxes to create, POST /v1/sandboxes/{id}/exec for a blocking run that returns stdout, stderr and exit_code, POST /v1/sandboxes/{id}/exec/stream for SSE, and GET/PUT/DELETE /v1/sandboxes/{id}/fs for files, with a bearer token and JSON throughout. The client at the bottom of this post is the whole thing, and it is about 120 lines of stdlib Ruby.

Where it is not the right fit, and this list is real: no Ruby SDK means you own a client, its tests and its failure modes. The base template does not pre-warm Ruby, so a serious Ruby shop is baking a template, which is a build pipeline you now have. vCPU and RAM are frozen into the snapshot at bake time and cannot change at restore, so sizing a run means baking a different template rather than passing a number. And if what you want is a hosted button that runs a Ruby snippet and returns its output with no infrastructure thinking at all, this is more machinery than your problem deserves.

E2B

The most focused entry in the category, and focus is a feature — E2B does sandboxes for AI agents and does not try to also be a cloud platform, so its documentation stays on the thing you are doing. Firecracker-backed per its own infrastructure docs, hosted-first with an open-source core, and a code-interpreter heritage visible in the ergonomics. From a Ruby seat the entire question is how well the HTTP and streaming surfaces are documented for people who are not using the Python or JavaScript clients, and whether a spec exists you can read field names out of. Check both against their current docs rather than inferring from the language badges on the landing page. Where it is not the right fit: anything adjacent your product needs — a database, app hosting — is a different vendor.

Daytona

Daytona comes at this from the development-environment direction rather than the ephemeral-invocation one: sandboxes feel like machines you work in. For Ruby that shape is a better fit than it is for most languages, because the whole problem with Ruby agent loops is that setup is expensive and you want to keep it warm. A workspace where the bundle survives and the Rails boot cache is hot maps neatly onto how a coding agent actually iterates on a Rails repo. Its docs describe a dedicated-kernel, complete-isolation model without naming a hypervisor, so I will not name one either. Open-source under AGPL-3.0 with managed, self-hosted and hybrid deployment; read that licence against your distribution plans before you build a product on it.

Modal's centre of gravity is serverless AI and ML compute — GPU jobs, batch inference, training-adjacent work — with a Sandbox primitive alongside it, and it is genuinely excellent at that job. The nuance for a Ruby team is sharper than for most: Modal is a Python-first platform where the programming model is the product. You define images, functions and apps in Python, and the sandbox lives inside that model rather than beside it. That is a good trade when the real workload is a GPU task with a sandbox attached. It is a lot of ceremony when the requirement from your Rails app is 'run this string somewhere safe', and it means introducing a Python deployment artifact into a Ruby shop's release process, which is a bigger organisational cost than it looks on a slide. Separately, Modal's own security documentation describes gVisor as the isolation mechanism — a user-space kernel rather than hardware virtualization. That is a considered choice and a real step up from a plain container; evaluate it as the different bet it is. Hosted-only.

Judge0

Worth calling out because Ruby teams find it and it is a legitimate answer to a narrower question. Judge0 is an open-source code-execution API in the online-judge tradition: you submit a source string and a language id, it runs it under resource limits and gives you back stdout, stderr, exit status and timing. Ruby is a supported language among many. It is self-hostable, the API is dead simple, and if your requirement really is 'evaluate this self-contained snippet and tell me what it printed' — a coding-education product, an interview tool, a calculator — it is much less machinery than a microVM platform.

Where it stops being the answer is the moment your agent needs a filesystem that persists between calls, a git checkout, a bundle, a database, or a long-running process. The submission model is one-shot by design. It is also worth being careful about the isolation story if you self-host: the sandboxing in that lineage is built on cgroups and namespaces on a shared kernel, and the security posture depends heavily on how you deploy it. For arbitrary model-generated code from untrusted users, read the deployment documentation with your security hat on rather than assuming the defaults suit your threat model. If you have already outgrown it, there is a separate post on that at /blog/best-judge0-alternatives-2026.

Vercel Sandbox, Cloudflare, and the TypeScript-native platforms

Short entry, because the honest advice is short. Vercel Sandbox is TypeScript-first by design and tightly coupled to the Vercel AI SDK, and that coupling is the entire selling point — if your agent lives in a Next.js app the integration tax is near zero. Vercel states plainly that sandboxes run as Firecracker microVMs. Cloudflare's edge story is excellent and Durable Objects are an unusually good fit for agent state, but a V8 isolate is a JavaScript boundary rather than a machine and will not run bundle exec rspec, so model-generated Ruby needs their container-based path, which is a different product with a different isolation story.

From a Rails service, both are products whose value is being inside an ecosystem you are not inside. You would be a foreign consumer of an HTTP API designed for someone else, giving up the integration benefit that is the reason the product exists. That is usually the wrong trade before you have even read the docs.

Self-hosted: Docker, gVisor, and raw Firecracker

Three different things that get lumped together, so separate them. Plain Docker driven from Ruby — the docker-api gem, or Testcontainers if you are already using it in your test suite — is genuinely the best developer experience on this list, because those are mature Ruby libraries and you already understand the model. It is also namespaces and cgroups around a process on the host's one kernel. That is appropriate for code you wrote and reviewed and a bet on kernel bug-freeness for code a model wrote. If a container is what you can ship this quarter, ship it, put a VM boundary around the whole fleet, set a seccomp profile and drop capabilities — just do not describe it to your security reviewer as a sandbox without the qualifier.

gVisor (runsc) is a real step up you can operate today: a user-space kernel intercepts most syscalls before they reach the host, and it drops in as an OCI runtime so most of your existing tooling survives. Compatibility and performance are workload-dependent and Ruby is an interesting case, because a Rails boot is syscall-heavy in exactly the ways that show up in gVisor benchmarks — measure with an actual bundle exec rspec, not a hello-world.

Raw Firecracker is the seductive one. The VMM is small and well-audited and a proof of concept boots in an afternoon, which is precisely the trap. The VMM was the easy ten percent. The other ninety is per-tenant networking that does not leak addresses between sandboxes, snapshot storage and a template pipeline, cross-host scheduling, and reaping orphaned VMs before they quietly bankrupt you. I have built exactly that platform and my original estimate was wrong by a large multiple. Do it if the substrate is strategic at your scale, and not because the hello-world was easy.

The plain VM answer: a box on Fly, Render, Hetzner or EC2

The option that never appears in these roundups and deserves to. Rent a small Linux VM, run an ephemeral user per job, cap it with systemd-run and cgroups, wipe the home directory afterwards, and drive it over SSH from your Rails app with net-ssh. Total infrastructure: one machine and a deploy script.

This is a completely defensible choice in two situations. First, when the code is only semi-trusted — your own users running their own scripts under their own account, where the threat is a mistake rather than an adversary. Second, when your volume is low enough that per-execution pricing on any platform would be more expensive than a machine you already have. The failure mode is that it does not scale in either dimension: one box is one blast radius, and the first time you need concurrency you are writing a scheduler. Be honest with yourself about which of those two situations you are in, and revisit it when you are not.

Everything above is qualitative on purpose, and everything is dated the moment it is published. SDKs get added, streaming protocols get rewritten, isolation backends occasionally get swapped, licences shift, and pricing moves faster than this post's shelf life. Use this to build a shortlist and to understand the criteria, then pull every specific claim live from each vendor's own current documentation — especially anything about Ruby support, which is the fastest-moving fact here. Then spend one afternoon on a real spike against your top two, with your Gemfile and your actual agent code. An hour of measurement settles more than a week of reading roundups, very much including this one.

At a glance, on the four dimensions a Ruby team cares about

  • PandaStack — Ruby client story: no first-party SDK; a small REST surface with bearer auth that is about 120 lines of stdlib Ruby to wrap, and no Ruby in the default base template so you bake your own. Isolation: Firecracker microVM with its own guest kernel. Warm-state story: snapshots plus copy-on-write forks at 400 to 750 milliseconds same-host, which is the answer to bundle install. Statefulness: explicit disposable-or-persistent choice with TTLs, and idle scales to zero.
  • E2B — Ruby client story: Python and JavaScript are the documented clients; from Ruby you are on the HTTP surface, so check what spec and streaming documentation exists for non-SDK consumers. Isolation: Firecracker microVMs per its own infrastructure docs. Warm-state story: sandbox sessions with documented lifetimes — read the current limits. Statefulness: session-oriented, hosted-first with an open-source core.
  • Daytona — Ruby client story: HTTP API plus its own SDKs; verify Ruby coverage against current docs. Isolation: described as dedicated-kernel and completely isolated, without naming a hypervisor. Warm-state story: strong, because the whole product is a workspace that persists — the best conceptual fit for an expensive Ruby bundle. Statefulness: by design. Licence: AGPL-3.0, read it against your distribution plans.
  • Modal — Ruby client story: none in spirit as well as in practice; the Python programming model is the product, so a Rails service integrates by adopting a second language's deployment artifact. Isolation: gVisor user-space kernel per Modal's security docs. Warm-state story: image-and-function shaped rather than machine shaped. Statefulness: sandboxes alongside serverless functions, hosted-only.
  • Judge0 — Ruby client story: a plain HTTP submission API, trivial to call from Ruby, and Ruby is a supported target language. Isolation: cgroups and namespaces on a shared kernel in the online-judge tradition — read the deployment docs carefully for untrusted input. Warm-state story: none, by design. Statefulness: one-shot submissions; there is no filesystem to keep.
  • Vercel Sandbox / Cloudflare — Ruby client story: effectively none; the value of both is TypeScript ecosystem adjacency and a Rails app is not adjacent. Isolation: Firecracker per Vercel's own statement; for Cloudflare, V8 isolates are a JS boundary and the container path is a separate product. Warm-state story: ephemeral and deploy-shaped. Statefulness: Durable Objects are a great state primitive and are not a filesystem.
  • Self-hosted Docker (docker-api, Testcontainers) — Ruby client story: the best on this list, because those are mature Ruby gems you may already use. Isolation: shared host kernel with the full syscall interface exposed. Warm-state story: excellent — commit a container or keep it running, and your bundle is right there. Statefulness: as long as you want, and orphan cleanup is your job.
  • Self-hosted Firecracker — Ruby client story: whatever you write, on top of a control plane you also write. Isolation: hardware-virtualized with its own guest kernel, the strongest on this list. Warm-state story: snapshots, once you have built snapshot storage. Statefulness: yours, including the orphan reaper you will write after the first surprise bill.
  • A plain VM on Fly, Render or Hetzner — Ruby client story: net-ssh and a shell, which is as simple as it gets. Isolation: users and cgroups on one shared kernel, one blast radius. Warm-state story: perfect, it is a persistent machine. Statefulness: total, including the state you did not want to keep, which is the security problem.

What this actually looks like in Ruby

Here is the part that pays for the afternoon. The code below is written against PandaStack's REST surface because that is the one I can describe exactly, but the shape transfers to any sandbox API with a bearer token and JSON bodies — swap the paths and field names and the rest holds.

Start with curl, always. Before you write a line of Ruby, prove the six calls you need work and see what the error bodies actually look like, because that is what your client's error handling has to parse.

export PANDASTACK_API_KEY=pds_...
export API=https://api.pandastack.ai

# 1. create — every create is a snapshot restore, so this returns fast
SB=$(curl -sS -X POST "$API/v1/sandboxes" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"template":"base","ttl_seconds":600}' | jq -r .id)
echo "sandbox: $SB"

# 2. blocking exec — stdout, stderr, exit_code come back as three JSON fields
curl -sS -X POST "$API/v1/sandboxes/$SB/exec" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cmd":"ruby -v || echo no-ruby-in-this-template","timeout_seconds":30}' | jq

# 3. write a file (raw body, path as a query param)
curl -sS -X PUT "$API/v1/sandboxes/$SB/fs?path=/tmp/agent.rb" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary 'puts RUBY_DESCRIPTION'

# 4. read it back
curl -sS "$API/v1/sandboxes/$SB/fs?path=/tmp/agent.rb" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY"

# 5. streaming exec — SSE, events are stdout | stderr | exit
curl -sS -N -X POST "$API/v1/sandboxes/$SB/exec/stream" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{"cmd":"for i in 1 2 3; do echo tick $i; sleep 1; done"}'

# 6. one deliberately broken request — what does a 401 look like?
curl -sS -i -X POST "$API/v1/sandboxes" -H "Authorization: Bearer nope" \
  -H "Content-Type: application/json" -d '{"template":"base"}' | head -20

curl -sS -X DELETE "$API/v1/sandboxes/$SB" -H "Authorization: Bearer $PANDASTACK_API_KEY"

Step six is the one people skip and it is the one that matters. If a 401 comes back as an HTML error page from a proxy rather than JSON, your JSON.parse is about to raise inside your error handler, which is a bug that only ever appears at three in the morning.

A dependency-free client, stdlib only

No gems. Net::HTTP, JSON and URI, which are all in the standard library, so this drops into a Rails app or a plain script with nothing added to the Gemfile. The important parts are the Struct for the exec result, the check! that raises on a non-zero exit so a failed command cannot silently look like a success, and the block form that tears the sandbox down in an ensure even when the block raises.

# lib/sandboxes/client.rb
# frozen_string_literal: true
#
# A dependency-free client for a sandbox REST API. Stdlib only.

require "net/http"
require "json"
require "uri"

module Sandboxes
  Error = Class.new(StandardError)

  class ApiError < Error
    attr_reader :status, :body

    def initialize(status, body)
      @status = status
      @body = body
      super("sandbox API returned #{status}: #{body.to_s[0, 400]}")
    end

    # Worth retrying? Create can 503 under capacity pressure; exec cannot be
    # blindly retried because it is not idempotent.
    def transient?
      [429, 502, 503, 504].include?(status)
    end
  end

  ExecResult = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true) do
    def ok?
      exit_code.zero?
    end

    def check!
      return self if ok?

      raise Error, "command exited #{exit_code}: #{stderr.to_s[0, 800]}"
    end

    # What you hand back to the model. Truncate: a 400k stack trace is both
    # useless to the model and expensive.
    def to_tool_output(limit: 8_000)
      body = ok? ? stdout : "#{stdout}\n--- stderr ---\n#{stderr}"
      body = body[0, limit] + "\n[truncated]" if body.length > limit
      { exit_code: exit_code, output: body }
    end
  end

  class Client
    DEFAULT_BASE = "https://api.pandastack.ai"

    def initialize(api_key: ENV.fetch("PANDASTACK_API_KEY"),
                   base_url: ENV.fetch("PANDASTACK_API", DEFAULT_BASE),
                   open_timeout: 5,
                   read_timeout: 120)
      @api_key = api_key
      @base = URI.parse(base_url.chomp("/") + "/")
      @open_timeout = open_timeout
      @read_timeout = read_timeout
    end

    def create(template: "base", ttl_seconds: 900, metadata: {})
      post("v1/sandboxes", template: template, ttl_seconds: ttl_seconds, metadata: metadata)
    end

    def exec(id, cmd, timeout_seconds: 120)
      data = post("v1/sandboxes/#{id}/exec", cmd: cmd, timeout_seconds: timeout_seconds)
      ExecResult.new(
        stdout: data["stdout"].to_s,
        stderr: data["stderr"].to_s,
        exit_code: data["exit_code"].to_i
      )
    end

    def write_file(id, path, content)
      request(Net::HTTP::Put, "v1/sandboxes/#{id}/fs?path=#{esc(path)}",
              body: content, content_type: "application/octet-stream")
    end

    def read_file(id, path)
      request(Net::HTTP::Get, "v1/sandboxes/#{id}/fs?path=#{esc(path)}", raw: true)
    end

    # POST /v1/sandboxes/{id}/fork — copy-on-write clone of a running sandbox.
    def fork_sandbox(id)
      post("v1/sandboxes/#{id}/fork")
    end

    def destroy(id)
      request(Net::HTTP::Delete, "v1/sandboxes/#{id}")
    rescue ApiError => e
      # A 404 on teardown means someone else already reaped it. Not an error.
      raise unless e.status == 404
    end

    # Always use this. `return` from inside the block still runs the ensure.
    def with_sandbox(**opts)
      sandbox = create(**opts)
      id = sandbox.fetch("id")
      begin
        yield id
      ensure
        begin
          destroy(id)
        rescue StandardError => e
          warn "[sandboxes] teardown of #{id} failed: #{e.class}: #{e.message}"
        end
      end
    end

    private

    def esc(str)
      URI.encode_www_form_component(str)
    end

    def post(path, **body)
      request(Net::HTTP::Post, path, body: JSON.generate(body),
                                     content_type: "application/json")
    end

    def request(klass, path, body: nil, content_type: nil, raw: false)
      uri = URI.join(@base, path)
      req = klass.new(uri)
      req["Authorization"] = "Bearer #{@api_key}"
      req["Accept"] = raw ? "*/*" : "application/json"
      req["Content-Type"] = content_type if content_type
      req.body = body if body

      res = Net::HTTP.start(uri.hostname, uri.port,
                            use_ssl: uri.scheme == "https",
                            open_timeout: @open_timeout,
                            read_timeout: @read_timeout) { |http| http.request(req) }

      raise ApiError.new(res.code.to_i, res.body) unless res.is_a?(Net::HTTPSuccess)
      return res.body.to_s if raw

      res.body.to_s.empty? ? {} : JSON.parse(res.body)
    rescue JSON::ParserError => e
      # The 401-is-an-HTML-page case. Fail with the body, not a parser error.
      raise ApiError.new(res.code.to_i, "non-JSON response: #{e.message}")
    end
  end
end

Using it is three lines, and the ensure means a raise inside your agent loop does not leave a machine running and billing.

client = Sandboxes::Client.new

client.with_sandbox(template: "ruby-agent", ttl_seconds: 600) do |id|
  client.write_file(id, "/tmp/task.rb", 'puts "ruby #{RUBY_VERSION} says hello"')
  puts client.exec(id, "ruby /tmp/task.rb").check!.stdout
end
Note what is not in that exec call: an env parameter. The exec endpoint takes a command string and a timeout, and nothing else, so environment variables go in the command itself — DATABASE_URL=... bundle exec rspec — or into a file you write first and source. That is a deliberate simplification on my side and it is the kind of thing you only discover by reading the reference, which is exactly why criterion one on the list above is 'a documented REST surface'. Ask the same question of every vendor: how do I get a secret into the guest without putting it in a process listing?

The Faraday version, for when it is already in your Gemfile

If you are in a Rails app, Faraday is probably already there as a transitive dependency and using it buys you connection pooling, middleware and a test adapter for your own specs. Two gotchas since Faraday 2: retry and the persistent adapter are separate gems, and the retry middleware must not be allowed near a non-idempotent exec.

# Gemfile
#   gem "faraday", "~> 2.0"
#   gem "faraday-retry"
#   gem "faraday-net_http_persistent"

require "faraday"
require "faraday/retry"
require "faraday/net_http_persistent"

module Sandboxes
  def self.connection
    @connection ||= Faraday.new(url: ENV.fetch("PANDASTACK_API", "https://api.pandastack.ai")) do |f|
      f.request :authorization, "Bearer", -> { ENV.fetch("PANDASTACK_API_KEY") }
      f.request :json
      f.response :json, content_type: /\bjson$/

      # Retry ONLY the safe verbs. POST /exec is deliberately excluded: a read
      # timeout does not mean the command did not run, and re-running a
      # migration because the socket hiccuped is a genuinely bad afternoon.
      f.request :retry,
                max: 3,
                interval: 0.25,
                backoff_factor: 2,
                retry_statuses: [429, 502, 503, 504],
                methods: %i[get delete]

      f.options.open_timeout = 5
      f.options.timeout = 120
      f.adapter :net_http_persistent, pool_size: 16
    end
  end

  # Create is safe to retry even though it is a POST, because a duplicate
  # sandbox costs pennies and a TTL reaps it. Do it explicitly, not by
  # loosening the middleware for every POST on the connection.
  def self.create_with_retry(template:, ttl_seconds: 900, attempts: 3)
    tries = 0
    begin
      tries += 1
      res = connection.post("v1/sandboxes", { template: template, ttl_seconds: ttl_seconds })
      raise ApiError.new(res.status, res.body) unless res.success?

      res.body
    rescue ApiError => e
      raise unless e.transient? && tries < attempts

      sleep(0.25 * (2**(tries - 1)))
      retry
    end
  end
end

Streaming exec, so the user sees the build instead of a spinner

A blocking exec is right for most tool calls. It is wrong for a bundle install or a full spec run, where the human wants to watch. The streaming endpoint is Server-Sent Events, which is just lines on a chunked response body, and Net::HTTP reads that fine with read_body and a block.

One detail in here is a bug I shipped and then fixed in my own Python SDK, so learn it from my expense report rather than yours: if the stream ends without an exit event — the host died, the agent restarted, the machine got reaped mid-command — an implementation that initialises exit_code to zero returns success with partial output. Your agent then confidently tells the user the specs passed. Start it as nil and raise if it never arrives.

module Sandboxes
  class Client
    # Streams a command, yielding [:stdout | :stderr, chunk], returns exit code.
    def exec_stream(id, cmd, timeout_seconds: 900)
      uri = URI.join(@base, "v1/sandboxes/#{id}/exec/stream")
      req = Net::HTTP::Post.new(uri)
      req["Authorization"] = "Bearer #{@api_key}"
      req["Accept"] = "text/event-stream"
      req["Content-Type"] = "application/json"
      req.body = JSON.generate(cmd: cmd, timeout_seconds: timeout_seconds)

      exit_code = nil # NOT 0 — see below.

      Net::HTTP.start(uri.hostname, uri.port,
                      use_ssl: uri.scheme == "https",
                      open_timeout: @open_timeout,
                      read_timeout: timeout_seconds + 60) do |http|
        http.request(req) do |res|
          raise ApiError.new(res.code.to_i, res.read_body) unless res.is_a?(Net::HTTPSuccess)

          event = nil
          buffer = +""

          res.read_body do |chunk|
            buffer << chunk
            while (nl = buffer.index("\n"))
              line = buffer.slice!(0..nl).chomp

              if line.start_with?("event:")
                event = line.delete_prefix("event:").strip
              elsif line.start_with?("data:")
                data = line.delete_prefix("data:")
                data = data[1..] if data.start_with?(" ")

                case event
                when "stdout" then yield(:stdout, data)
                when "stderr" then yield(:stderr, data)
                when "exit"   then exit_code = extract_exit(data)
                end
              end
            end
          end
        end
      end

      # If the stream ended without an exit event the command did NOT succeed —
      # the host went away mid-run. Returning 0 here means your agent reports a
      # killed spec suite as a green one. Ask me how I know.
      raise Error, "exec stream ended with no exit event" if exit_code.nil?

      exit_code
    end

    private

    def extract_exit(data)
      JSON.parse(data)["exit_code"].to_i
    rescue JSON::ParserError
      data.strip.to_i
    end
  end
end

Wiring that into a Rails app is straightforward if you keep it out of the request cycle. Run it from a Sidekiq job, push each chunk to an ActionCable channel, and let the browser render it.

# app/jobs/agent/run_specs_job.rb
class Agent::RunSpecsJob < ApplicationJob
  queue_as :agents

  def perform(run_id, sandbox_id, spec_path)
    client = Sandboxes::Client.new
    channel = "agent_run_#{run_id}"

    code = client.exec_stream(
      sandbox_id,
      "cd /srv/app && bundle exec rspec #{Shellwords.escape(spec_path)}",
      timeout_seconds: 1_800
    ) do |stream, chunk|
      ActionCable.server.broadcast(channel, { stream: stream, text: chunk })
    end

    ActionCable.server.broadcast(channel, { stream: :exit, exit_code: code })
    AgentRun.find(run_id).update!(status: code.zero? ? "passed" : "failed")
  end
end

Two Rails patterns worth stealing

The generic 'run a snippet' example is not why you are here. Here are the two shapes that actually come up when an agent works on a Rails codebase.

Rehearsing a generated migration before a human sees it

The model wrote a migration. Before it goes near a branch, run it in a disposable microVM against a disposable database, capture what it did to the schema, and then roll it back to prove the down path works. If the rollback fails, you have caught the single most common defect in generated migrations — an irreversible change written as if it were reversible — before it reaches a review, let alone production.

# app/services/agent/migration_rehearsal.rb
# frozen_string_literal: true

require "shellwords"

module Agent
  class MigrationRehearsal
    # A template you baked with your Ruby, your gems and a local Postgres, so
    # the sandbox does not spend four minutes in `bundle install` per attempt.
    TEMPLATE = "rails-agent"

    Result = Struct.new(:ok, :reversible, :schema_diff, :log, keyword_init: true)

    def initialize(repo_url:, git_ref: "main", client: Sandboxes::Client.new)
      @repo_url = repo_url
      @git_ref = git_ref
      @client = client
    end

    def rehearse(filename, source)
      @client.with_sandbox(template: TEMPLATE, ttl_seconds: 1_200) do |id|
        sh = ->(cmd, t = 300) { @client.exec(id, cmd, timeout_seconds: t) }

        # Clone at a pinned ref. Shellwords.escape on anything the model or a
        # user supplied, always — the command is a shell string on the wire.
        sh.call("git clone --depth 1 --branch #{Shellwords.escape(@git_ref)} " \
                "#{Shellwords.escape(@repo_url)} /srv/app", 300).check!

        # Gems are baked into the template; this is a fast no-op unless the
        # branch changed Gemfile.lock, in which case it does the delta only.
        sh.call("cd /srv/app && bundle install --jobs 4 --quiet", 900).check!

        # The generated migration goes in via the filesystem endpoint, not via
        # a heredoc in a shell command. Model output contains quotes.
        @client.write_file(id, "/srv/app/db/migrate/#{filename}", source)

        env = "DATABASE_URL=postgres://postgres@127.0.0.1:5432/rehearsal RAILS_ENV=test"
        before = sh.call("cd /srv/app && #{env} bin/rails db:prepare && cat db/schema.rb", 600)
        return failed(before) unless before.ok?

        up = sh.call("cd /srv/app && #{env} bin/rails db:migrate", 900)
        return failed(up) unless up.ok?

        after = sh.call("cd /srv/app && cat db/schema.rb")
        down = sh.call("cd /srv/app && #{env} bin/rails db:rollback STEP=1", 600)

        Result.new(
          ok: true,
          reversible: down.ok?,
          schema_diff: diff(before.stdout, after.stdout),
          log: [up.stdout, up.stderr, down.stdout, down.stderr].join("\n")
        )
      end
    end

    private

    def failed(result)
      Result.new(ok: false, reversible: false, schema_diff: nil,
                 log: "#{result.stdout}\n#{result.stderr}")
    end

    def diff(before, after)
      # Whatever you like here — Diffy, or a plain line comparison. The point is
      # that the model gets a schema delta rather than 900 lines of schema.rb.
      (after.lines - before.lines).join
    end
  end
end

Three things in there are the whole lesson. Shellwords.escape on every interpolated value, because the exec body is a shell string and the model's branch name is not your friend. The migration source goes through the filesystem endpoint rather than a heredoc, because model output contains quote characters and eventually contains a delimiter that looks like your heredoc terminator. And the return inside the with_sandbox block still runs the ensure, so the early exits do not leak a machine.

On the database: for a rehearsal you want Postgres inside the same VM, because it is instant and disposable and nothing outside the boundary is at risk. When you want the rehearsal to run against something shaped like production instead, that is where a managed database with branching earns its keep — clone the real database into a new one, point the sandbox at the clone, and throw it away when the run ends. Branching a database is the same idea as forking a sandbox, applied to the part of the state you cannot regenerate.

Forking a warm sandbox to try three fixes at once

This is the pattern that made me build the thing, so treat the enthusiasm accordingly. A Ruby agent loop is expensive at the front — clone, bundle, migrate, boot — and cheap at the back, where the actual attempt is one small patch and one spec run. That asymmetry is the argument for forking.

Get one sandbox to the warm state: repo cloned, gems installed, database migrated, specs passing. Then fork it once per candidate patch. On PandaStack a same-host fork is 400 to 750 milliseconds because memory is copy-on-write and the disk is an XFS reflink, so three children cost about two seconds total rather than three full setups. Apply a different patch in each, run the same spec file in each, and keep the first one that goes green. The rest get deleted and cost nothing further.

# Try N candidate patches in parallel from one warm parent.
def try_patches(parent_id, patches, spec_path, client: Sandboxes::Client.new)
  children = patches.map do |patch|
    child = client.fork_sandbox(parent_id)
    [child.fetch("id"), patch]
  end

  results = children.map do |child_id, patch|
    Thread.new do
      client.write_file(child_id, patch[:path], patch[:contents])
      r = client.exec(child_id,
                      "cd /srv/app && bundle exec rspec #{Shellwords.escape(spec_path)}",
                      timeout_seconds: 900)
      { id: child_id, patch: patch, passed: r.ok?, output: r.to_tool_output }
    end
  end.map(&:value)

  winner = results.find { |r| r[:passed] }
  results.each { |r| client.destroy(r[:id]) unless r.equal?(winner) }
  winner
end

Two honest caveats. Threads are the right concurrency primitive here because these are blocking IO calls and the GVL releases during IO, but if you are doing this inside Puma you are competing for the same threads that serve requests — put it in a job. And fork is not free at scale: three children of a 4 GiB parent are three sandboxes as far as capacity and billing are concerned, even if the copy-on-write means they share most of their pages on the host. Fork wins on latency and on setup cost, not on memory accounting.

Read the report off the filesystem, do not parse stdout

Last small thing, and it will save you more prompt tokens than any other change in this post. When your agent runs specs, do not hand the model four thousand lines of dots and a stack trace. Have RSpec write its JSON report to a file, pull the file down through the filesystem endpoint, and give the model the three failures.

cmd = "cd /srv/app && bundle exec rspec " \
      "--format progress --format json --out /tmp/rspec.json"
run = client.exec(id, cmd, timeout_seconds: 1_800)

report = JSON.parse(client.read_file(id, "/tmp/rspec.json"))

failures = report.fetch("examples", []).select { |e| e["status"] == "failed" }.map do |e|
  {
    file: e["file_path"],
    line: e["line_number"],
    description: e["full_description"],
    message: e.dig("exception", "message").to_s[0, 1_200]
  }
end

summary = report["summary"] # example_count, failure_count, pending_count, duration

model_input = {
  passed: run.ok?,
  counts: summary,
  failures: failures.first(5)
}

The dual-formatter trick matters: progress to stdout so a human watching the stream still sees something happening, JSON to a file so your code gets structure. And truncate the exception messages, because one ActiveRecord validation error can serialise into several thousand tokens of object inspection.

The decision guide

  • Stop looking for a Ruby SDK and start reading REST references. The SDK matrix is the wrong filter. In Ruby a competent client over a clean HTTP surface is an afternoon and 120 lines of stdlib; a bad HTTP surface is forever.
  • Write the curl script before the Ruby. Create, exec, stream, write a file, read it back, delete, and one deliberately broken request. Ten minutes of curl tells you more about a platform than its entire marketing site.
  • Pick a focused agent-sandbox product — PandaStack, E2B, Daytona — if the sandbox is part of your product and you want lifecycle, cleanup and safety semantics designed for you rather than assembled by you at two in the morning.
  • Pick PandaStack specifically if you want microVM isolation with cheap per-turn creates, snapshot and copy-on-write forking to amortise the bundle install that dominates every Ruby loop, idle scaling to zero, and a managed Postgres with branching next door — accepting that Ruby means the REST API, that you will bake your own template because base does not pre-warm Ruby, and that vCPU and RAM are fixed at bake time.
  • Pick Daytona if your agents live in long-running workspaces where a warm bundle and a hot Rails boot cache are worth more than a fast create, and the AGPL-3.0 licence fits how you distribute your product.
  • Pick Modal if the real workload is GPU or batch compute with a sandbox attached, you are willing to run a Python deployment artifact alongside your Rails app, and gVisor's boundary satisfies your threat model after you have actually read about it rather than assumed.
  • Pick Judge0 if the job genuinely is 'evaluate this self-contained snippet and return what it printed' — education products, interview tools, a calculator in your app. It is far less machinery than a microVM platform and it is the right size for that problem. Read its deployment security docs carefully if the input is adversarial.
  • Do not pick Vercel Sandbox or Cloudflare's TypeScript-native path from a Rails service. Their value is ecosystem adjacency and you are not adjacent; you would pay the integration cost and skip the benefit.
  • Pick a plain VM with per-job users and cgroups if the code is semi-trusted and your volume is low. It is a legitimate answer, it is cheap, and it is honest as long as you say out loud that it is one blast radius.
  • Pick Docker with seccomp and dropped capabilities if it is what you can ship this quarter, and put a VM boundary around the fleet. Just do not call it a sandbox in the security review without the qualifier.
  • Do not use eval, ever, for anything a model or a user wrote. No binding, no denylist, no refinement makes it safe, and $SAFE has been gone since Ruby 3.0.
  • Do not put a two-minute sandbox call in a controller action. Sidekiq or ActiveJob, with the server-side timeout shorter than the job's.

The bottom line

There is no best sandbox API for Ruby agents, and the honest headline is that there is barely a Ruby-specific answer at all — which is fine, because Ruby's real disadvantage here is not the missing SDK, it is that the language makes running a string so pleasant that a lot of teams never get as far as asking the question. eval with a clean binding feels like a decision. It is not one.

The evaluation you should run is not 'who supports my language'. It is: are the URLs resource-shaped, do errors come back as machine-readable codes rather than prose, is the exit code out of band from the output, does a timeout reach the guest or only my socket, and can I keep a warm sandbox around so I am not paying for bundle install on every turn. Those five questions will separate the field faster than any comparison table, and they are all answerable with curl in an afternoon.

PandaStack's bet, for the record: Firecracker microVMs with a snapshot restore on every create at 179ms p50, copy-on-write forks at 400 to 750 milliseconds so a warm bundle can be branched instead of rebuilt, idle scaling to zero so a sandbox you are not using does not bill, managed Postgres with branching next door, and a REST surface small enough to wrap in the 120 lines above. No Ruby SDK, no Ruby in the default base template, and I am not going to pretend otherwise. If that trade fits your loop, benchmark it against the field and keep me honest. If it does not — if you need one-shot snippet evaluation, or a workspace that lives for a week, or just a box you already own — one of the others above genuinely fits you better, and I would rather you use it than churn off mine in six months.

Frequently asked questions

Is there a Ruby SDK for any of these code-execution sandbox platforms?

I am deliberately not asserting that for other vendors in a blog post, because SDK availability is the fastest-moving fact in the whole comparison and an outdated claim would cost you more than it saved. Check rubygems.org, the vendor's GitHub organisation and their docs' language list, then note the date you checked. For PandaStack I can answer directly: no, there is no first-party Ruby SDK. Python and TypeScript are the official SDKs, and Ruby teams use the REST API. Treat SDK availability as a convenience factor rather than a gate, because a competent Ruby client over a clean REST surface is roughly 120 lines of standard-library code using Net::HTTP, JSON and URI, with no gems added to your Gemfile.

Can I safely run model-generated Ruby with eval and a clean binding?

No. A binding scopes local variables and does nothing about constants, and in Ruby every interesting capability is a constant reachable from Object. Code inside eval can reach ActiveRecord::Base, Rails.application.credentials, ENV, File and Kernel#system, redefine methods your web requests are about to call, and walk the object graph with ObjectSpace. Denylisting dangerous words does not help either, because send, const_get and runtime string construction defeat any pattern match. Ruby's old taint-tracking safe levels were deprecated in 2.7 and removed in 3.0, so $SAFE no longer does anything. Ruby 3.x has no supported in-process mechanism for confining untrusted code; you need a process boundary at minimum and a kernel boundary to describe it accurately to a security reviewer.

Is Process.spawn or fork enough isolation for untrusted Ruby?

It is enough for reliability and not for security. A child process gives you crash isolation and a clean way to enforce a wall-clock timeout, so a runaway script no longer takes down your Puma worker. But that child runs as your deploy user, on your kernel, with your working directory, your environment variables, your bundle config, your outbound network and your cloud instance metadata endpoint one HTTP request away. Under Puma with preload_app! it is worse: the master has already booted Rails, decrypted your credentials into memory and opened the connection pool, and a fork inherits that heap. Use a separate process by all means, then put a real boundary around it: a container with seccomp at minimum, a microVM with its own guest kernel if the code came from a model.

How do I avoid running bundle install on every agent turn?

Bake it or snapshot it. A realistic Rails Gemfile compiles native extensions for gems like nokogiri, pg and bcrypt, so a cold bundle install is measured in minutes and will dominate your agent loop entirely if you pay it per turn. The two fixes are: build a custom sandbox template with your Ruby version and your gems already installed, so a create starts from the post-install state; and keep one sandbox alive across the agent's turns rather than creating a fresh one per tool call. If your platform supports forking a running sandbox, that is the best version of both — install once in a warm parent, then fork per attempt, which on PandaStack is 400 to 750 milliseconds same-host because memory is copy-on-write and the disk is an XFS reflink.

How should a Rails app call a sandbox API without blocking a Puma thread?

Put it in a background job. A two-minute rspec run holding a Puma thread and a database connection from the pool is a self-inflicted outage on a busy box, and it will be the first thing that falls over under load. Enqueue an ActiveJob or Sidekiq job, use the streaming exec endpoint, and broadcast each output chunk to the browser over ActionCable or expose a polled status endpoint. Set the job's own timeout longer than the sandbox's per-exec timeout so the server-side limit is the one that fires, and always set a TTL on the sandbox at create time as a backstop, because a restarted dyno between create and cleanup otherwise leaves a machine running.

How do I get RSpec results back in a form an LLM can actually use?

Have RSpec write its JSON report to a file inside the sandbox and pull that file down through the filesystem endpoint, rather than parsing stdout. Run it with two formatters at once: progress to stdout so a human watching the stream still sees activity, and json to a path like /tmp/rspec.json. Then read the file, select the examples whose status is failed, and hand the model the file path, line number, full description and a truncated exception message for the first few failures plus the summary counts. That turns four thousand lines of dots and stack traces into a few hundred tokens the model can act on, and truncation matters because one ActiveRecord validation error can serialise into thousands of tokens of object inspection.

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.