The Best Sandbox APIs for Ruby and Rails AI Agents in 2026
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.
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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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
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.
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
endUsing 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
endThe 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
endStreaming 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
endWiring 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
endTwo 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
endThree 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
endTwo 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
- The best sandbox APIs for LLM agents in 2026 — the language-agnostic version of this comparison
- The best sandbox APIs for Java AI agents — the other no-SDK language, same evaluation
- Running bundle install on untrusted Ruby code in a microVM — why native gem compilation is its own threat
- How to sandbox untrusted code
- Best Rails hosting platforms in 2026
- Sandboxes on PandaStack — Firecracker microVMs behind a small REST API
49ms p50 cold start. Fork, snapshot, and scale to zero.