The Best Sandbox APIs for Java AI Agents in 2026
The LLM tooling ecosystem decided, without a vote, that agents are written in Python or TypeScript. Meanwhile a genuinely large amount of agent work is being shipped on the JVM: LangChain4j in a Spring Boot service, Spring AI wired into an existing enterprise app, Quarkus with the LangChain4j extension, or the plainest version of all — a Kotlin service calling the Anthropic or Bedrock SDK in a loop it wrote itself. Those teams have exactly the same problem as everyone else. At some point the model emits code, and something has to run it.
I'm Ajay, I build PandaStack, which is one of the entries below, so read this as a vendor's roundup and discount accordingly. What I can offer in exchange is the post nobody writes for the JVM. And it opens with a finding that is more useful than any ranking: almost no sandbox vendor ships a first-party Java SDK. Not mine either — PandaStack has official Python and TypeScript SDKs, and Java teams use the REST API or generate a client from the OpenAPI spec. I'd rather say that in paragraph two than let you find it in a footer.
Which reframes the whole evaluation. A Python team asks "how good is the SDK?" You are asking "how good is the HTTP surface, and how much of a client am I about to write?" That is a cheaper question to answer — an afternoon with curl and an OpenAPI generator settles most of it — and Java in 2026 is unusually well equipped for the answer, because `java.net.http.HttpClient` has been in the JDK since 11 and handles JSON, SSE-shaped line streams and per-request timeouts without a single dependency.
First, the bad news about sandboxing inside the JVM
Java shops reach for a Java answer first, and there are three of them. All three are worse than they look, and one of them no longer exists.
The SecurityManager is deprecated, disabled, and going away
For twenty-five years the reflexive answer to "how do I run untrusted Java safely" was a restricted classloader plus a SecurityManager with a hand-written policy file. JEP 411 deprecated the Security Manager for removal in Java 17, and JEP 486 permanently disabled it in JDK 24 — `System.setSecurityManager` now throws at runtime, and the `-Djava.security.manager=allow` escape hatch that carried people through 18 through 23 is gone with it. If your plan is "run it in a restricted classloader," your plan is a JDK upgrade away from being an UnsupportedOperationException in production.
This was the right call, incidentally. The Security Manager was a permission model checked at hundreds of call sites, with a well-earned reputation for being one missed check away from irrelevant, and almost nobody configured it correctly. But its removal means the JVM has no supported in-process mechanism for confining untrusted code, and no amount of clever classloader work brings one back. A hostile class file and your application code share a heap, a thread pool, and a process.
ProcessBuilder is a fork, not a boundary
The next idea is a separate JVM: shell out with `ProcessBuilder`, cap the heap with `-Xmx`, maybe drop it into its own working directory. This is real progress for reliability — an OOM in the child no longer takes your web server with it — and approximately zero progress on security. That child is still your process tree, running as your service account, on your kernel, with your filesystem, your environment variables, your instance metadata endpoint, and your outbound network. `-Xmx` is a heap ceiling, not a resource limit; the child can still spawn threads, open sockets, and read `/proc`. One `Runtime.getRuntime().exec` from the model and you are executing arbitrary shell as the user that owns your production service.
The subtle version of this failure is worse than the obvious one. Nobody deliberately runs model output as root. What happens is that the sandboxing is real but partial — a temp directory, a timeout, a stripped environment — and then a build step reads `~/.m2/settings.xml`, or a test picks up `AWS_WEB_IDENTITY_TOKEN_FILE`, and suddenly the model's code has your artifact-repository credentials. Partial confinement mostly buys you the confidence to stop looking.
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 you a separate filesystem and process view, cgroups give you real CPU and memory limits, seccomp cuts the syscall table down, dropping capabilities removes most of the interesting ones. Do all of it. But be precise about what you have: one kernel, shared between your host and everything running on it, with the entire Linux syscall interface as the attack surface for code that no human reviewed. A container is a polite suggestion to the kernel, and the kernel has historically been suggestible.
For first-party code you wrote and reviewed, that posture is perfectly reasonable and I would not spend a sprint changing it. For code an LLM generated — possibly under the influence of a prompt injection sitting in a Confluence page your agent read forty seconds ago — a hardware-virtualized boundary is the right default, because then each execution gets its own guest kernel and your exposed surface is a small, heavily audited VMM instead of all of Linux. More on that reasoning in /blog/how-to-sandbox-untrusted-code.
The criteria that actually matter to a JVM shop
Ten things, ordered roughly by how much time they will cost you. The first three are Java-specific; the rest apply to everybody but land differently when you are the one writing the client.
- A documented REST surface with a machine-readable spec. This is the whole ballgame. If there is a published OpenAPI 3.x document, your "SDK" is one Gradle task away from existing, and you get typed models, correct nullability and a transport layer for free. If the API is documented only as prose examples in Python, you are transcribing curl commands by hand and discovering undocumented fields in production.
- 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 Java record in one line and onto a tool method 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.
- Streaming over something the JDK can read. SSE and chunked HTTP are lines on a response body, and `HttpResponse.BodyHandlers.ofLines()` gives you a `Stream<String>` with no dependency at all. A bespoke WebSocket framing documented only inside a TypeScript client means you are reverse-engineering a protocol, probably with Tyrus or OkHttp bolted on.
- Session and state model. Does the machine survive between tool calls? For how long, and what happens when it idles because your agent is waiting on a model response? An agent that re-clones the repo and re-runs `mvn dependency:go-offline` every turn has made setup the dominant cost of the entire loop — and Maven and Gradle dependency resolution is not a cheap thing to redo.
- Filesystem upload and download as first-class endpoints. You will be shipping a source tree in and a JAR or a test report out. Base64-through-shell works until the model emits a quote character, and it always eventually emits a quote character. Check the size ceiling before you need to move a 60MB shaded artifact.
- Per-sandbox network egress policy. A perfectly isolated microVM with unrestricted internet still exfiltrates everything you put in it. Look for policy you can set at create time per sandbox, not an account-wide firewall rule someone in platform engineering owns.
- Startup latency, measured on your template. This determines whether you create a sandbox per turn or hold one per task. Anything in the low hundreds of milliseconds means fresh-per-call is affordable; anything in the tens of seconds forces you into session management and idle-reaping logic you now maintain.
- Server-enforced timeouts and TTLs. A client-side timeout that returns control to your thread while the guest keeps executing 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 pod gets evicted 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; only one of them is a hardware boundary.
- Self-host and licence. Sometimes a hard requirement — data residency, an air-gapped customer, an auditor with opinions — and sometimes a preference that costs you an engineer. Enterprise Java shops disproportionately land in the first category, so check what is actually open source: the client, the runtime, or the control plane.
One more: does blocking exec play nicely with virtual threads?
This is the Java-specific detail nobody else's roundup will mention, and it is genuinely good news. An agent's sandbox calls are long, blocking, IO-bound HTTP requests — exactly the workload virtual threads were built for. On a modern JDK with `Executors.newVirtualThreadPerTaskExecutor()`, a thousand concurrent agent turns each blocked on a two-minute build cost you a thousand parked continuations rather than a thousand platform threads, and the boring synchronous code you would have written anyway is suddenly the correct code. `HttpClient.send` is a blocking call that yields properly.
Two caveats. First, if you or a library you depend on is holding a `synchronized` block across the blocking call, you pin the carrier thread and lose the benefit — use `ReentrantLock` in anything that guards a sandbox session. Second, the JDK's own `HttpClient` maintains an internal selector and connection pool, so a single shared instance with a sensible `connectTimeout` behaves far better under a thousand virtual threads than a thousand clients do. Build one, reuse it, and put your deadlines on the individual requests.
The field, through a JVM 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 feature matrices.
PandaStack (mine — read accordingly)
Open-source (Apache-2.0) Firecracker microVMs, self-hostable end to end on any Linux box with `/dev/kvm`, with a hosted service on the same binaries so moving between them is a base-URL change. The Java story, stated plainly: there is no first-party Java SDK. Python and TypeScript are the official SDKs; JVM teams use the REST API directly or generate a client from the OpenAPI spec. The surface is deliberately small — `POST /v1/sandboxes` to create, `POST /v1/sandboxes/{id}/exec` for a blocking run returning stdout, stderr and exit code, `POST /v1/sandboxes/{id}/exec/stream` for SSE, and `GET`/`PUT /v1/sandboxes/{id}/fs` for files. Bearer token, JSON in, JSON out. On numbers, and only for my own system: create is a snapshot restore on every call with no warm pool, landing at 179ms p50 and roughly 203ms p99, with only the first-ever spawn of a brand-new template cold-booting at about 3 seconds to bake its snapshot. Forking a warm sandbox is 400–750ms same-host and 1.2–3.5s cross-host. Per-sandbox networking comes from 16,384 pre-allocated /30 subnets per agent host, which is where egress policy hangs. Where it is not the right fit: no Java SDK means you own the client; vCPU and RAM are baked into the snapshot and cannot change at restore, so per-run memory sizing means re-baking a template rather than passing a number (the `base` template is 4 GiB and 8 vCPU); and self-hosting is real operational weight.
E2B
The most focused entry in the category, and focus is a feature — E2B does sandboxes for AI agents and does not try to be a cloud platform, so the docs stay on the thing you are doing. Firecracker-backed per its own infrastructure docs, hosted-first with an Apache-2.0 open-source core, and a code-interpreter heritage visible in the ergonomics. From a JVM seat the question is entirely about how well the HTTP and streaming surfaces are documented for non-SDK consumers, and whether a spec exists you can generate from — check both against the current docs rather than inferring from the language badges on the landing page. Where it is not the right fit: anything else your product needs, like a database or app hosting, is a separate vendor. See /blog/best-e2b-alternatives-2026.
Modal
Modal's centre of gravity is serverless AI/ML compute — GPU jobs, batch inference, training-adjacent work — with a Sandbox primitive alongside, and it is genuinely excellent at that. The nuance for a Java 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 if your real workload is a GPU task with a sandbox attached; it is a lot of ceremony to adopt from a Spring Boot service whose entire requirement is "run this string somewhere safe," and it means introducing a Python deployment artifact into a JVM shop's release process. 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.
Daytona
Daytona approaches this from the development-environment direction rather than the ephemeral-invocation one: sandboxes feel like machines you work in, which maps well onto an agent operating inside a long-lived workspace rather than firing a thousand disposable creates an hour. That shape suits a lot of JVM agent work, honestly — a Gradle build cache and a warm `~/.m2` are worth keeping around. 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, which is advice enterprise Java teams generally do not need to be given twice.
Vercel Sandbox
Worth being direct: this 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. If your agent is a Spring Boot service, you would be reaching across an ecosystem boundary for a product whose value is being inside that ecosystem — usually the wrong trade, before you even check what the non-TypeScript path looks like. Vercel states plainly that sandboxes run as Firecracker microVMs; the client SDK is open source, the runtime is not, and there is no self-host path.
Cloudflare (Workers, Containers, Sandbox SDK)
Cloudflare's edge story is unmatched for startup and distribution, and Durable Objects fit agent state unusually well. The distinction that matters for you: a V8 isolate is a JavaScript boundary, not a machine — it will not run a Maven build or arbitrary Python — so model-generated code needs the container-based path, which is a different product with a different isolation story. The whole platform is also aggressively TypeScript-native, which means a JVM service consuming it is consuming an HTTP API written for someone else. Verify the current shape of the container and sandbox offerings against Cloudflare's own docs; this part of their lineup has moved quickly.
Runloop
Aimed squarely at the coding-agent case, with primitives shaped around what code agents actually do: durable dev boxes, repo-aware setup, and evaluation scaffolding for measuring whether your agent is getting better. That last part matters more than it sounds, because most teams eventually build a worse version of it internally, right at the point where they can no longer tell whether last week's prompt change helped. Where it is not the right fit: a specialized platform is a bet on your use case staying that shape, and as with any newer entrant, verify the isolation model, the language support and the API surface against current docs rather than assuming.
Self-host: gVisor, raw Firecracker, and plain Docker
Three different things that get lumped together. 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 existing container tooling mostly survives. Compatibility and performance are workload-dependent, and JVM workloads are a genuinely interesting case here — measure with an actual `mvn test`, 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. Then you discover the VMM was the easy 10%, and the other 90% 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; my estimate was wrong by a large multiple. And plain Docker via docker-java or Testcontainers deserves the blunt version: it is namespaces and cgroups around a process on the host's one kernel. It is a fine choice 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 and put a VM boundary around the whole fleet — just do not tell your security reviewer it is a sandbox.
At a glance, on the four dimensions a JVM team cares about
- PandaStack — Java client story: no first-party SDK; REST plus an OpenAPI spec you generate from, and the surface is small enough to hand-roll in an afternoon. Isolation boundary: Firecracker microVM, own guest kernel, self-hostable Apache-2.0. Streaming exec: SSE over plain HTTP, readable with BodyHandlers.ofLines(). Statefulness: explicit choice of disposable or persistent, with TTLs, snapshots and copy-on-write forks.
- E2B — Java client story: Python and TypeScript are the documented SDKs; from the JVM you are on the HTTP surface, so check what spec and streaming documentation exists for non-SDK consumers. Isolation boundary: Firecracker microVMs per its own infrastructure docs, hosted-first with an open-source core. Streaming exec: verify the current wire format against their docs. Statefulness: sandbox sessions with documented lifetimes — read the current limits.
- Modal — Java client story: none in spirit as well as in practice; the Python programming model is the product, so a JVM service integrates by adopting a second language's deployment artifact. Isolation boundary: gVisor user-space kernel per Modal's security docs. Streaming exec: designed around the Python client. Statefulness: sandboxes alongside a serverless function model, hosted-only.
- Daytona — Java client story: HTTP API plus its own SDKs; verify JVM coverage against current docs. Isolation boundary: described as dedicated-kernel and completely isolated without naming a hypervisor. Streaming exec: workspace-shaped, so exec and terminal semantics resemble a dev box more than an invocation API. Statefulness: strongly stateful by design — the point is a workspace that persists.
- Vercel Sandbox — Java client story: effectively none; the product's value is coupling to the Vercel AI SDK in TypeScript. Isolation boundary: Firecracker microVMs per Vercel's own statement, hosted-only, runtime not open source. Streaming exec: through the TypeScript client. Statefulness: ephemeral per-invocation, sized for a deploy-adjacent workload.
- Cloudflare (Workers / Containers) — Java client story: TypeScript-native platform; a JVM caller is a foreign consumer of an HTTP API. Isolation boundary: V8 isolates for Workers, which is a JS boundary rather than a machine; the container path is a separate product with a separate model. Streaming exec: platform-native primitives, not a general exec API. Statefulness: Durable Objects are an excellent state primitive but are not a filesystem.
- Runloop — Java client story: verify against current docs; positioned around agent SDKs rather than JVM ones. Isolation boundary: verify, and make them say whether it is a guest kernel. Streaming exec: verify. Statefulness: durable dev boxes are a core primitive, which suits repo-resident coding agents.
- Self-hosted gVisor — Java client story: whatever you write; you are operating a runtime, not consuming an API. Isolation boundary: user-space kernel intercepting syscalls, a real improvement over a plain container. Streaming exec: yours to build. Statefulness: yours to build, which is the recurring theme of this row.
- Self-hosted Firecracker — Java client story: none; you are building the platform, and the control plane is the project, not the VMM. Isolation boundary: hardware-virtualized, own guest kernel, the strongest on this list. Streaming exec: yours. Statefulness: yours, including snapshot storage and orphan reaping.
- Plain Docker (docker-java, Testcontainers) — Java client story: genuinely the best on this list, since docker-java and Testcontainers are mature JVM libraries. Isolation boundary: shared host kernel, full Linux syscall interface exposed — appropriate for first-party code, not for code a model wrote. Streaming exec: attach and log-follow APIs that work fine. Statefulness: containers persist as long as you keep them, and orphan cleanup is on you.
What this actually looks like in Java
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. Records for the wire types, one shared `HttpClient`, deadlines on the request rather than the client, and a teardown path that runs even when the happy path did not.
package com.example.agent.sandbox;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** Minimal client over a REST sandbox API. Records in, records out. */
public final class SandboxClient implements AutoCloseable {
// Wire types as records: Jackson binds them directly, they are immutable,
// and they read like the JSON they came from.
public record Sandbox(String id, String template, String status) {}
public record ExecResult(String stdout, String stderr, int exit_code) {}
/** A typed failure so call sites branch on a code, not on substring matching. */
public static final class SandboxApiException extends RuntimeException {
public final int status;
SandboxApiException(int status, String body) {
super("sandbox api " + status + ": " + body);
this.status = status;
}
/** Never retry a 4xx. You will hit the same wall, only faster. */
public boolean retryable() { return status == 429 || status >= 500; }
}
private final String baseUrl;
private final String apiKey;
private final ObjectMapper json = new ObjectMapper();
// One client, reused. Under virtual threads a shared client with a pooled
// connection manager behaves far better than one client per task.
// Note there is no global read timeout: a build legitimately runs for
// minutes, so the deadline belongs on each individual request.
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
public SandboxClient(String baseUrl, String apiKey) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}
private HttpRequest.Builder request(String path, Duration timeout) {
return HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.timeout(timeout);
}
private <T> T send(HttpRequest req, Class<T> type) throws IOException, InterruptedException {
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 300) {
throw new SandboxApiException(res.statusCode(), res.body());
}
return json.readValue(res.body(), type);
}
/** ttlSeconds is the backstop: if this JVM dies before kill(), the VM reaps itself. */
public Sandbox create(String template, int ttlSeconds) throws IOException, InterruptedException {
String body = """
{"template":"%s","ttl_seconds":%d}""".formatted(template, ttlSeconds);
HttpRequest req = request("/v1/sandboxes", Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return send(req, Sandbox.class);
}
/** timeoutSeconds is enforced inside the guest, which is the half that matters. */
public ExecResult exec(String id, String cmd, int timeoutSeconds)
throws IOException, InterruptedException {
String body = json.writeValueAsString(
java.util.Map.of("cmd", cmd, "timeout_seconds", timeoutSeconds));
HttpRequest req = request("/v1/sandboxes/" + id + "/exec",
Duration.ofSeconds(timeoutSeconds + 15L))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return send(req, ExecResult.class);
}
public void writeFile(String id, String path, String content)
throws IOException, InterruptedException {
HttpRequest req = request("/v1/sandboxes/" + id + "/fs?path="
+ java.net.URLEncoder.encode(path, java.nio.charset.StandardCharsets.UTF_8),
Duration.ofSeconds(60))
.PUT(HttpRequest.BodyPublishers.ofString(content))
.build();
http.send(req, HttpResponse.BodyHandlers.discarding());
}
public void kill(String id) {
try {
http.send(request("/v1/sandboxes/" + id, Duration.ofSeconds(15)).DELETE().build(),
HttpResponse.BodyHandlers.discarding());
} catch (IOException | InterruptedException e) {
// Cleanup is best-effort by design. The TTL set at create() is the
// real guarantee; this call is just the polite version.
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
}
}
@Override public void close() { http.close(); }
}Then the streaming path, which is where a REST-only sandbox is either pleasant or miserable from the JVM. The good news is that SSE is lines on a response body, and the JDK hands you those as a `Stream<String>` with no dependency and no framing library. The detail worth internalising: take the exit code from its own event, never by pattern-matching the output text, because a test runner will eventually print your sentinel verbatim and you will spend a day on it.
import java.util.function.BiConsumer;
import java.util.stream.Stream;
/**
* Consumes POST /v1/sandboxes/{id}/exec/stream, an SSE endpoint emitting
* stdout / stderr / exit events. Note what is absent: no SSE library, no
* WebSocket client, no extra dependency. BodyHandlers.ofLines() is enough.
*/
public int streamExec(String id, String cmd, BiConsumer<String, String> onChunk)
throws IOException, InterruptedException {
String body = json.writeValueAsString(
java.util.Map.of("cmd", cmd, "timeout_seconds", 600));
HttpRequest req = request("/v1/sandboxes/" + id + "/exec/stream", Duration.ofMinutes(20))
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<Stream<String>> res = http.send(req, HttpResponse.BodyHandlers.ofLines());
if (res.statusCode() != 200) {
throw new SandboxApiException(res.statusCode(), "stream failed");
}
// Mutable holders because we are folding a line stream into two values.
var event = new String[]{""};
var exit = new int[]{-1};
try (Stream<String> lines = res.body()) {
lines.forEach(line -> {
if (line.isEmpty()) {
event[0] = ""; // blank line ends an SSE event
} else if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).stripLeading();
try {
var node = json.readTree(data);
switch (event[0]) {
case "stdout", "stderr" -> onChunk.accept(event[0], node.path("text").asText());
// The exit code arrives out of band as its own event.
// Parsing it out of stdout is how you ship a bug that
// only fires when a test prints the word "exit".
case "exit" -> exit[0] = node.path("exit_code").asInt(-1);
default -> { }
}
} catch (IOException ignored) {
// A malformed frame is not worth failing the whole run over.
}
}
});
}
if (exit[0] < 0) {
// Stream ended with no exit event: the guest died, a proxy cut an idle
// connection, or the sandbox was reaped. Whatever it was, not success.
throw new IOException("stream ended before exit event");
}
return exit[0];
}And now the bit you are actually here for: turning that into something the model can call. This is a LangChain4j-style `@Tool` method, and the shape is the same in Spring AI (`@Tool` on a bean method registered as a `ToolCallback`) or in a hand-rolled loop where you build the JSON schema yourself. Everything interesting is in the error handling — a tool that throws kills the turn, whereas a tool that returns the failure text lets the model read the stack trace and fix its own code.
package com.example.agent.tools;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import com.example.agent.sandbox.SandboxClient;
/**
* One sandbox per agent run, not per tool call. Re-creating a machine for
* every turn means re-installing dependencies every turn, and setup then
* becomes the dominant cost of the whole loop.
*
* Not thread-safe on purpose: if your framework fires parallel tool calls,
* either give each one its own instance or guard it with a ReentrantLock.
* Do NOT use synchronized -- it pins the carrier thread under virtual threads.
*/
public class PythonTool implements AutoCloseable {
private static final int MAX_FEEDBACK_CHARS = 4000;
private final SandboxClient client;
private final String sandboxId;
public PythonTool(SandboxClient client) throws Exception {
this.client = client;
// 30-minute TTL: the backstop for the case where this JVM is killed
// between here and close(), which will happen during a rolling deploy.
this.sandboxId = client.create("code-interpreter", 1800).id();
}
@Tool("Run Python code in an isolated sandbox and return its output. "
+ "Use this to compute, test, or verify anything before answering.")
public String runPython(@P("Python source to execute") String code) {
try {
client.writeFile(sandboxId, "/tmp/tool.py", code);
var r = client.exec(sandboxId, "python3 /tmp/tool.py", 60);
if (r.exit_code() != 0) {
// Return the failure instead of throwing. stderr is the highest
// value thing a sandbox gives an agent: it is the correction
// signal, and the model is genuinely good at reading tracebacks.
return "FAILED (exit %d)%n%s".formatted(r.exit_code(), truncate(r.stderr()));
}
// Branch on exit code, never on "is stderr empty". Plenty of
// well-behaved tools write to stderr and exit 0.
return truncate(r.stdout());
} catch (SandboxClient.SandboxApiException e) {
return e.retryable()
? "Sandbox temporarily unavailable, try again shortly."
: "Sandbox rejected the request: " + e.getMessage();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "Execution interrupted.";
} catch (Exception e) {
return "Execution failed: " + e.getMessage();
}
}
/** Keep the tail: the exception is at the bottom of a Python traceback. */
private static String truncate(String s) {
if (s == null || s.length() <= MAX_FEEDBACK_CHARS) return s == null ? "" : s;
return "...[truncated]...\n" + s.substring(s.length() - MAX_FEEDBACK_CHARS);
}
@Override public void close() { client.kill(sandboxId); }
}That is roughly 200 lines total for a complete integration: lifecycle, blocking exec, streaming, file upload, typed errors, cleanup. If you would rather not write even that, and the vendor publishes an OpenAPI document, generate the transport layer and hand-write only the ergonomic wrapper on top — which is the pattern most teams converge on anyway.
# If the vendor ships an OpenAPI 3.x document, your "SDK" is a build step.
# The java library uses java.net.http, so this adds no HTTP dependency.
openapi-generator-cli generate \
-i https://api.example.com/openapi.json \
-g java \
--library native \
-p useJakartaEe=true,openApiNullable=false,serializationLibrary=jackson \
-o ./sandbox-client
# Then check the two things that decide whether this was worth it:
# 1. Are the model classes sane, or is every field a nested Optional?
# 2. Is there a streaming operation at all -- generators routinely drop
# text/event-stream endpoints, which is exactly the one you needed.
# If the answer to (2) is no, generate the transport and hand-write streaming.
# Full walkthrough: /blog/how-to-generate-a-typed-api-client-from-an-openapi-specThe decision guide
- Stop looking for a Java SDK and start reading REST references. The SDK matrix is the wrong filter; on the JVM, a competent client over a clean HTTP surface is an afternoon, and a bad HTTP surface is forever.
- Write the curl script before the Java. Create, exec, stream, write a file, delete, and one deliberately broken request. If a 401 comes back as an HTML page from a proxy rather than JSON, your Jackson error handling is about to become a parse failure wearing a trench coat.
- Pick a focused agent-sandbox product (PandaStack, E2B, Runloop) if the sandbox is the feature and you want lifecycle, cleanup and safety semantics designed for you rather than assembled by you.
- Pick PandaStack specifically if you want microVM isolation with cheap per-turn create, copy-on-write forking for branch-and-retry agent patterns, and the option to run the whole substrate in your own datacentre — accepting that Java means the REST API, that vCPU and RAM are fixed at snapshot-bake time, and that self-hosting is real work.
- Pick Daytona if your agents live in long-running workspaces where a warm Maven or Gradle cache is worth more than a fast create, and the AGPL-3.0 licence fits your distribution.
- Pick Modal if the real workload is GPU or batch compute with a sandbox attached, you are willing to introduce a Python deployment artifact next to your JVM services, and gVisor's boundary satisfies your threat model after you have actually read about it.
- Do not pick Vercel Sandbox or Cloudflare's TypeScript-native path from a JVM service. Their value is ecosystem adjacency, and a Spring Boot app is not adjacent.
- Pick self-hosted gVisor if you need a better boundary this quarter with your existing container tooling, and raw Firecracker only if the substrate is strategic at your scale and you can staff a team for it.
- Pick plain ProcessBuilder if the code is first-party code you wrote and reviewed. Wrapping a trusted script in a microVM buys latency and an on-call surface against a threat that is not in your model.
- Do not pick a restricted classloader for anything, ever again. It is deprecated, disabled in current JDKs, and was never the boundary people believed it was.
The bottom line
There is no best sandbox API for Java agents, and the honest headline is that there is barely a Java-specific answer at all — which is fine, because the JVM's real advantage here is not SDK availability, it is that a decent HTTP client, JSON binding, records and virtual threads are all in the box. The evaluation you should run is not "who supports my language" but "whose REST surface will I still like in six months": are the URLs resource-shaped, do errors come back as codes rather than prose, is streaming readable by `ofLines()`, is the exit code out of band, is there a spec I can generate from, and does a timeout reach the guest or only my socket.
And be clear-eyed about the part that has nothing to do with Java. The SecurityManager is gone, a child JVM is still your process tree, and a container is a shared kernel with good ergonomics. If your agent runs code a model wrote, the boundary needs to be a boundary. PandaStack's bet, for the record, is an Apache-2.0 Firecracker core with a small REST surface — 179ms p50 create, 400–750ms same-host forks, SSE streaming exec, first-class filesystem endpoints and TTLs that reap — which you can run end to end on your own hardware and drive from about 200 lines of Java. No Java SDK, 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, one of the others above genuinely fits you better, and I would rather you use that than churn off mine in six months.
Frequently asked questions
Is there a Java SDK for any of these sandbox platforms?
I am deliberately not asserting that in a blog post, because it is the fastest-moving fact in the whole comparison and an outdated claim would cost you more than it saved. Check Maven Central, the vendor's GitHub organisation, and their docs' language list, then note the date. For PandaStack I can answer directly: no, there is no first-party Java SDK. Python and TypeScript are official; JVM teams use the REST API or generate a client from the OpenAPI spec. Treat SDK availability as a convenience factor rather than a gate, because in Java a competent client over a clean REST surface is roughly 200 lines of standard-library code.
Can I still use the Java SecurityManager to sandbox model-generated code?
No. JEP 411 deprecated the Security Manager for removal in Java 17, and JEP 486 permanently disabled it in JDK 24, so setSecurityManager throws at runtime and the java.security.manager=allow escape hatch is gone. Even when it worked it was a permission model checked at hundreds of call sites, one missed check away from irrelevant, and almost nobody configured it correctly. There is now no supported in-process mechanism for confining untrusted code inside a JVM. If the code came from a model, it needs a process boundary at minimum and a kernel boundary if you want to describe it accurately to a security reviewer.
Is running the code in a separate JVM process good enough?
It is good enough for reliability and not for security. A child process started with ProcessBuilder gets you crash isolation and a heap ceiling from -Xmx, so a runaway allocation no longer takes down your web server. But that child runs as your service account, on your kernel, with your filesystem, your environment variables, your instance metadata endpoint and your outbound network. One Runtime.getRuntime().exec from the model and you are running arbitrary shell in production. Use a separate process by all means, then put a real boundary around it: a container at minimum, a microVM with its own guest kernel if the code is untrusted.
How do sandbox calls interact with virtual threads?
Very well, which is the nicest surprise in this whole area. Agent sandbox calls are long, blocking, IO-bound HTTP requests, and that is exactly what virtual threads were built for, so a thousand concurrent agent turns each blocked on a two-minute build cost you parked continuations rather than platform threads. HttpClient.send blocks and yields properly. Two traps: never hold a synchronized block across the blocking call, because that pins the carrier thread, so use ReentrantLock in anything guarding a sandbox session. And share one HttpClient instance rather than creating one per task, since it maintains its own selector and connection pool.
Should I create a sandbox per tool call or per agent run?
Per run, in almost every case. Fresh-per-call is the safer default and it is correct for stateless one-shot tools, but for an agent iterating on a repository it is usually the wrong economics: if every turn re-clones the repo and re-resolves Maven or Gradle dependencies, setup becomes the dominant cost of the loop in both latency and spend. Carry the sandbox ID in whatever object already represents the run, alongside the conversation history. Always set a TTL at create time so a crashed JVM or an evicted pod does not leave a machine running, and if your framework fires parallel tool calls, guard the shared sandbox or give each call its own.
Keep reading
- The best sandbox APIs for LLM agents in 2026 — the language-agnostic version of this comparison
- The best sandbox APIs for Go AI agents
- Generating a typed API client from an OpenAPI spec
- How to sandbox untrusted code
- Sandboxes on PandaStack — Firecracker microVMs behind a small REST API
49ms p50 cold start. Fork, snapshot, and scale to zero.