all posts

Best Code-Execution Sandbox APIs for Go AI Agents in 2026

Ajay Kumar··10 min read

I'm Ajay; I built PandaStack, which is one of the entries below, so treat this as a vendor's roundup and discount accordingly. What I can offer in exchange is a post aimed at a reader nobody writes for: the person building an agent in Go. Maybe you're on langchaingo, maybe on the official Anthropic Go SDK, maybe — most likely, because this is Go — on a hand-rolled loop of 300 lines you understand completely. Either way, something eventually has to run the code the model wrote, and the options are: run it here, or run it somewhere else. Running model-generated `exec.Command("sh", "-c", ...)` on the same host as your API is a bold scaling strategy, and I've reviewed enough repos to know it's also a popular one.

So you go shopping, and you hit the thing every Go agent author hits. The docs open with Python. Then TypeScript. Then, somewhere in the footer, "REST API reference." That's your SDK. This post is about evaluating sandbox platforms from that position: what a good HTTP surface looks like from Go, the checklist to run every vendor through, an honest pass over the field, and roughly 200 lines showing how to wrap any REST sandbox in a Go client that doesn't embarrass you. The Python cut of this argument is at /blog/best-sandbox-apis-for-coding-agents-python-2026 and the TypeScript one at /blog/best-sandbox-apis-for-typescript-agents-2026.

Disclosure and ground rules. PandaStack is mine. Specific numbers — latency, fork times, subnet counts — appear only for my own system, where I can point at the code that produces them; every other platform is described qualitatively in terms of what it's positioned for. One rule I'm holding to especially hard in this post: I will not tell you whether any vendor does or does not ship an official Go SDK, because that is exactly the kind of fact that changes between when I write this and when you read it, and getting it wrong would be worse than useless. Check pkg.go.dev, the vendor's GitHub org, and the last commit date yourself, then note the date you checked. Same for limits, quotas, and pricing. This is a buyer's checklist, not a spec sheet.

Why Go agent authors have a different problem

The centre of gravity in AI tooling is Python and TypeScript, and sandbox vendors follow their users. That's not a conspiracy, it's arithmetic: a first-party SDK is a permanent maintenance commitment — versioned, documented, supported at 2am — and you build it where the demand is. So Go tends to get the REST reference and a friendly shrug. Sometimes a generated OpenAPI client. Sometimes a community client written by one enthusiastic person in 2025.

This inverts your evaluation. A Python team asks "how good is the SDK?" You're asking "how good is the HTTP surface, and how much of an SDK will I be writing?" The good news is that this is a much cheaper question to answer — ten minutes with curl tells you most of it — and Go is unusually well-equipped for the answer. `net/http`, `encoding/json`, and `bufio` make a competent API client a boring afternoon rather than a project. The bad news is that nobody is going to fix a bad HTTP surface for you, and some of them are bad in ways that a Python SDK papers over so smoothly the vendor may not know.

The REST surface is your SDK, so evaluate it as one

What makes an HTTP API pleasant from Go is specific and checkable. Resource-shaped, stable URLs, so your client is a handful of methods rather than an RPC dispatch table. JSON bodies with consistent key casing that map onto struct tags without a custom `UnmarshalJSON`. Errors that come back as JSON with a machine-readable code, not an HTML page from whatever proxy sits in front. A streaming endpoint that speaks SSE or plain chunked HTTP rather than a bespoke WebSocket framing you'll be reverse-engineering from a JavaScript client. Deletes that are idempotent, because your cleanup path will fire twice. Long operations that return something pollable rather than holding a connection open for four minutes through a load balancer with a 60-second idle timeout. None of that requires reading a single line of Go — it's all visible in the API reference and confirmable with curl in one sitting.

Generated clients, community clients, and writing your own

If a Go client exists, figure out which of three things it is. A **first-party SDK** is designed by someone who has written an agent: a sandbox type with a lifecycle, an ergonomic exec, typed errors. A **generated OpenAPI client** is a faithful transcription of the HTTP surface — every field a `*string`, context threaded only as far as the HTTP call, no session object, and a `ClientWithResponses` type whose method names are a spec author's naming choices rather than yours. It works; it just means you're writing the ergonomic layer anyway, now on top of 40,000 generated lines. A **community client** can be excellent — check the last commit, whether it tracks the current API version, and whether it's load-bearing for anyone but its author, because "archived, see fork" is a bad thing to discover during an incident. And the fourth option, unusual to recommend but often right in Go specifically: write the 200 lines yourself. In Python that advice would be silly. In Go the stdlib is good enough that a hand-rolled client is smaller, more debuggable, and better fitted to your agent than anything generic — and you'll actually understand the cancellation semantics, which is the whole ballgame.

context.Context that reaches the guest, not just the socket

Go programmers have a strong cultural expectation that cancellation propagates. Pass a context, cancel it, everything downstream unwinds. That expectation is exactly wrong at a network boundary, and sandboxes are the case where it costs money. There are three levels, and only one of them is real. **Level zero**: no context support, so you can't cancel at all. **Level one**: the context cancels your HTTP request, your goroutine returns cleanly, and the microVM on the other side continues executing the infinite loop the model wrote until something else notices — that's not a cancellation, it's a leak with good manners. **Level two**: cancelling actually stops the process inside the guest. Assume you're getting level one unless the docs say otherwise, and build level two yourself: wire `ctx.Done()` to an explicit kill or delete call, set a server-enforced per-exec timeout, and put a TTL on the sandbox as the backstop for the case where your process is SIGKILLed and never runs cleanup at all. Then test it deliberately — cancel a turn mid-build and go check whether the sandbox is actually gone, because this failure mode is completely silent until it appears on an invoice.

The checklist a Go team should actually use

Ten things, roughly in the order they'll cost you time. The first four are Go-specific; the rest apply to everyone but hit differently when you're the one writing the client.

  1. Context propagation — does a context deadline or cancellation reach the remote process, or only your socket? Ask explicitly, because the docs almost never distinguish, and the gap between the two is the difference between a timeout and a leak.
  2. Streaming exec over the stdlib — is there an SSE or chunked-HTTP endpoint you can read with bufio.Scanner, or is streaming only available through a WebSocket protocol documented in a TypeScript client? Also check whether the exit code arrives out-of-band as its own event, or whether you're expected to parse it out of the text like an animal.
  3. Typed errors — does the API return a stable machine-readable error code in JSON, so your client can expose something callers errors.As() into? The alternative is strings.Contains(err.Error(), "not found") scattered through your agent, which works until someone rewords a message in a patch release.
  4. Timeouts, retries, idempotency — a per-exec timeout enforced inside the guest, a TTL on the sandbox, retryable-vs-terminal status codes you can distinguish, and idempotent creates and deletes so your retry loop doesn't spawn a second machine you'll never hear about again.
  5. Filesystem primitives — first-class read and write endpoints beat base64-through-shell, which breaks the first time the model emits a quote character it shouldn't have. Confirm binary artifacts come back as bytes rather than as something you extract from stdout.
  6. Snapshot and fork — can you warm an environment once and branch it cheaply? This is the primitive that makes best-of-N repair affordable. Note that a snapshot you restore later is a backup and a fork of a live machine is a branch; several platforms have one and not the other.
  7. Persistent vs ephemeral lifecycle — does the machine survive between tool calls, for how long, and what happens when it idles because your agent is waiting on a model? An agent that re-clones the repo and re-installs dependencies every turn has made setup the dominant cost of the loop.
  8. Network egress control — a perfectly isolated microVM with unrestricted internet still exfiltrates whatever you put in it. Look for per-sandbox policy, not an account-level firewall. /blog/why-ai-agents-need-a-sandbox covers the half of the boundary everyone forgets.
  9. Self-host — sometimes a hard requirement (residency, air-gapped customer, an auditor), sometimes a preference that costs you an engineer. Be honest about which, and note that "self-hosted" covers both run-it-end-to-end open source and bring-your-own-cloud with a proprietary control plane.
  10. Pricing shape, not rate — I'm printing no prices for anyone, including myself, but shape outlives rate. Per-second, per-invocation, and per-seat behave completely differently for an agent loop, which spends most of its wall-clock time idle waiting on a model. The question that matters: what do you pay while nothing is running?

The checklist as a table you can take into a call

  • Cancellation — What good looks like: cancelling the request kills the process inside the guest, and there's a documented kill or delete endpoint you can call from a ctx.Done() handler. What to ask the vendor: "If my client disconnects mid-exec, does the command keep running, and for how long?"
  • Streaming — What good looks like: SSE or chunked HTTP, one event type per stream, exit code delivered as its own event. What to ask the vendor: "Can I consume streaming exec from a plain HTTP client with no SDK, and is the wire format documented?"
  • Errors — What good looks like: JSON error bodies with a stable `code` field distinct from the HTTP status, and documented retryable classes. What to ask the vendor: "Which status codes are safe to retry, and are creates idempotent if I send the same request twice?"
  • Timeouts — What good looks like: a per-exec timeout the server enforces inside the guest, plus a TTL on the sandbox itself. What to ask the vendor: "If my orchestrator dies right after create, what reaps this machine, and when?"
  • Filesystem — What good looks like: read, write, list, and stat as first-class endpoints handling binary cleanly. What to ask the vendor: "How do I get a 40MB build artifact out, and what's the size limit before I need object storage?"
  • Statefulness — What good looks like: an explicit choice between disposable and persistent, with documented idle behaviour. What to ask the vendor: "Does the filesystem survive between calls, does a process survive between calls, and what happens after ten idle minutes?"
  • Fork/snapshot — What good looks like: fork a running machine, not just restore a saved image, with documented same-host and cross-host behaviour. What to ask the vendor: "Does a fork capture live memory, or only disk?"
  • Egress — What good looks like: per-sandbox network policy — allowlist, denylist, or fully offline — set at create time. What to ask the vendor: "Can I create a sandbox with no internet access at all, in one API call?"
  • Isolation boundary — What good looks like: a clear statement of whether your code gets its own guest kernel, a user-space kernel, or namespaces on a shared host kernel. What to ask the vendor: "Does my code get its own kernel?" — "sandbox" is not a regulated term, so make them say it.
  • Self-host — What good looks like: a documented path to running the substrate yourself, with the licence stated plainly. What to ask the vendor: "What exactly is open source — the client, the runtime, or the control plane?"
  • Pricing shape — What good looks like: billing that tracks actual consumption rather than wall-clock existence. What to ask the vendor: "What's the bill for a sandbox that's alive for an hour and executes for ninety seconds of it?"

The field, through a Go lens

Grouped by the job each is genuinely positioned for, not ranked, because ranking them requires pretending they're the same product. Every entry carries the same caveat: verify SDK availability, isolation model, limits, and pricing against that vendor's own current docs before you commit, and note the date you read them.

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 one base-URL change moves between them. Being straight with you about the Go story: there are official Python and TypeScript SDKs, and for Go you use the REST API directly — `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 /v1/sandboxes/{id}/fs` for files. That's the surface the whole next section is built against, and it's deliberately small: bearer token, JSON in, JSON out, SSE for streaming, no WebSocket needed unless you want an interactive PTY. On the numbers, and only for my own system: create is snapshot-restore on every call with no warm pool — the restore step lands around 49ms, end-to-end create is 179ms p50 and roughly 203ms p99, and only the first-ever spawn of a brand-new template cold-boots at about 3 seconds to bake the snapshot. Forking a warm sandbox is 400–750ms same-host and 1.2–3.5s cross-host, which is what makes best-of-N repair affordable rather than aspirational. Per-sandbox networking comes from 16,384 pre-allocated /30 subnets per agent host, which is where egress policy hangs. Where it isn't the right fit: no official Go SDK means you're writing the client (about 200 lines, shown below, but still yours to maintain); vCPU and RAM are baked into the snapshot and can't be changed at restore, so per-run memory sizing means re-baking a template rather than passing a number; and self-hosting is real operational weight, so if you have no infra appetite a hosted-only API is genuinely less work.

E2B

The most focused entry in the category, and focus is a feature. E2B does sandboxes for AI agents and doesn't try to be a cloud platform, so the docs stay on the thing you're doing rather than the platform around it. Firecracker-backed per its own infrastructure docs, hosted-first with an Apache-2.0 open-source core, and a code-interpreter heritage that shows in the ergonomics. From a Go perspective the question is entirely about what language support looks like when you read this and how the HTTP and streaming surfaces are documented for non-SDK consumers — check both against the current docs and the repo rather than inferring from the marketing page. Where it isn't the right fit: anything else your product needs — a database, app hosting, a build pipeline — is a separate vendor. See /blog/pandastack-vs-e2b.

Modal's centre of gravity is serverless AI/ML compute — GPU jobs, batch inference, training-adjacent work — with a Sandbox primitive alongside, and it's genuinely excellent at that job. The relevant nuance for a Go team is that 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's a fine trade if your real workload is a GPU task with a sandbox attached; it's a lot of ceremony to adopt from a Go service whose entire requirement is "run this string somewhere safe." Separately, Modal's own security docs describe gVisor as the isolation mechanism — a user-space kernel rather than a hardware-virtualized VM. That's a considered choice and a real step up from a plain container; evaluate it as the different bet it is. Hosted-only. See /blog/pandastack-vs-modal.

Daytona

Daytona comes at this from the development-environment direction rather than the ephemeral-invocation one: sandboxes feel like machines you work in, which maps well onto agents operating inside a long-lived workspace rather than firing a thousand disposable creates an hour. Its docs describe a dedicated-kernel, complete-isolation model without naming a hypervisor, so I won't name one either. Open-source (AGPL-3.0) with managed, self-hosted, and hybrid deployment. For a Go team the interesting bit is that a workspace-shaped product tends to have a workspace-shaped API — create, connect, work, destroy — which is a smaller client to write than a high-churn invocation API, but a poorer fit if churn is your pattern. Read the licence against your distribution plans before you build a product on it. See /blog/pandastack-vs-daytona.

Vercel Sandbox

Worth being direct: this one 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 Go binary, you'd be reaching across an ecosystem boundary for a product whose value is being inside that ecosystem — usually the wrong trade, even before you 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's no self-host path. See /blog/pandastack-vs-vercel-sandbox.

Fly.io Machines

A lower-level, more general primitive than the agent-shaped sandbox APIs: fast-starting VMs you drive through an HTTP API, deployable near users, with persistent volumes and a scale-to-zero story. The bet differs in kind — real machines that stick around and cost little when idle, rather than cheap disposable creates. This is a comparatively comfortable option for a Go team precisely because there was never a pretence of an agent SDK: it's an HTTP API and you're going to write a client, which is the position you were in anyway. Just budget honestly for what "sandbox API" was hiding — the session lifecycle, the exec transport, the cleanup path, the safety layer, and the per-tenant egress policy are all now yours. See /blog/pandastack-vs-flyio-machines.

Northflank

Northflank is an application platform — build pipelines, services, jobs, managed databases, bring-your-own-cloud — that has extended toward agent workloads. Its genuine strength is breadth under one roof plus BYOC: if you want your agent's sandboxes, your Go API, your queues, and your Postgres on one control plane inside your own cloud account, that consolidation is real and underrated, and it's the kind of thing a platform team appreciates more than an application developer does. Where it isn't the right fit: if all you want is a narrow disposable-sandbox endpoint, a full platform is more surface than the job needs, and more surface means more API to wrap. Verify the isolation model and the sandbox-oriented feature set against Northflank's own docs. See /blog/pandastack-vs-northflank.

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 — 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 or hurt. Where it isn't the right fit: a specialized platform is a bet on your use case staying that shape, and as with any newer entrant you should verify the isolation model, the language support, and the API surface against current docs rather than assuming. See /blog/pandastack-vs-runloop.

DIY: gVisor, Kata, raw Firecracker, and Docker

Four genuinely different things that get lumped together, and Go teams are unusually susceptible to this bucket because the tooling is all in our language. **gVisor** (runsc) is a real step up you can self-operate today: a user-space kernel intercepts most syscalls before they reach the host, it drops in as an OCI runtime so existing container tooling mostly survives, and it's written in Go, which makes it pleasantly hackable. Compatibility and performance are workload-dependent — measure with your actual workload, not a hello-world. **Kata Containers** gives you a hardware-virtualized boundary behind a container-shaped interface, which is the best of both if your orchestration is already Kubernetes-shaped and the worst of both if it isn't. **Raw Firecracker via firecracker-go-sdk** is the seductive one: the SDK is Go, the VMM is small and well-audited, and a proof of concept that boots a microVM from your own binary takes an afternoon and feels fantastic. Then you discover that the VMM was the easy 10%. The other 90% is per-tenant networking that doesn't leak addresses between sandboxes, snapshot storage and a template pipeline, cross-host scheduling, and reaping orphaned VMs before they quietly bankrupt you. I've built exactly that; my estimate was wrong by a large multiple, and I'd make the same mistake again with more confidence. **Docker** via the Go client is the most common shipped answer and deserves the blunt version: a container is namespaces and cgroups around a process on the host's one kernel, so the entire Linux syscall interface is your attack surface for code nobody reviewed. Seccomp, dropped capabilities, rootless, read-only rootfs — all worth doing, none of it changes the shared-kernel fact. It's 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 don't tell your security reviewer it's a sandbox. More at /blog/build-vs-buy-firecracker-sandbox and /blog/why-docker-is-not-a-sandbox.

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

How to wrap any REST sandbox in a decent Go client

Here's the part that actually pays for your afternoon. The code below is written against PandaStack's REST surface because that's 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 the field names and the rest holds. Start with curl, though. If the surface isn't pleasant here, no amount of client code will rescue it:

# Ten minutes with curl predicts your whole integration. You are checking
# three things: are the URLs resource-shaped, is the JSON boring, and does an
# error come back as JSON rather than as an HTML page from a proxy.

curl -sS -X POST "$PANDASTACK_API/v1/sandboxes" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"template":"code-interpreter","ttl_seconds":600}'
# -> {"id":"...","template":"code-interpreter","status":"running"}

curl -sS -X POST "$PANDASTACK_API/v1/sandboxes/$ID/exec" \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cmd":"go version","timeout_seconds":30}'
# -> {"stdout":"go version ...\n","stderr":"","exit_code":0}

# And the one everybody forgets to test until production: what does a
# deliberately broken request look like? If this returns HTML, your Go error
# handling is going to be a JSON decode failure wearing a trench coat.
curl -sS -i -X POST "$PANDASTACK_API/v1/sandboxes" \
  -H "Authorization: Bearer definitely-not-a-real-key" \
  -H "Content-Type: application/json" -d '{}'

Now the client. Three things earn their keep and are usually the three that hand-rolled clients skip: a typed error callers can `errors.As` into, no timeout on the `http.Client` (deadlines belong on the context, per call, because exec legitimately runs for minutes), and a teardown path that doesn't reuse a cancelled context.

package sandbox

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

// APIError is the piece hand-rolled clients skip and then regret. Without it,
// every error path in your agent becomes strings.Contains(err.Error(),
// "not found"), which works right up until someone rewords a message.
type APIError struct {
	StatusCode int    `json:"-"`
	Code       string `json:"code"`
	Message    string `json:"message"`
}

func (e *APIError) Error() string {
	return fmt.Sprintf("sandbox api: %d %s: %s", e.StatusCode, e.Code, e.Message)
}

// Retryable keeps the retry policy next to the error instead of scattered
// across call sites. Never retry a 4xx -- you will hit the same wall, faster.
func (e *APIError) Retryable() bool {
	return e.StatusCode == http.StatusTooManyRequests || e.StatusCode >= 500
}

type Client struct {
	BaseURL string
	APIKey  string
	HTTP    *http.Client
}

func New(baseURL, apiKey string) *Client {
	// Deliberately no Timeout on the http.Client: a build or a test suite can
	// legitimately run for minutes, and a client-wide timeout would guillotine
	// it halfway. Deadlines belong on the context, per call.
	return &Client{BaseURL: baseURL, APIKey: apiKey, HTTP: &http.Client{}}
}

// do is a free function rather than a method because Go has no generic
// methods. Slightly awkward, entirely worth it for one typed decode path.
func do[T any](ctx context.Context, c *Client, method, path string, body any) (*T, error) {
	var rdr io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return nil, fmt.Errorf("encode %s %s: %w", method, path, err)
		}
		rdr = bytes.NewReader(b)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
	if err != nil {
		return nil, fmt.Errorf("build %s %s: %w", method, path, err)
	}
	req.Header.Set("Authorization", "Bearer "+c.APIKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.HTTP.Do(req)
	if err != nil {
		// Wraps context.Canceled / context.DeadlineExceeded, so callers can
		// errors.Is(err, context.Canceled) instead of guessing from a string.
		return nil, fmt.Errorf("%s %s: %w", method, path, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 300 {
		apiErr := &APIError{StatusCode: resp.StatusCode, Code: "unknown"}
		// LimitReader because one day a proxy will hand you a 4MB HTML error
		// page where the docs promised a tidy JSON object.
		_ = json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(apiErr)
		return nil, apiErr
	}
	if resp.StatusCode == http.StatusNoContent {
		return new(T), nil
	}

	var out T
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode %s %s: %w", method, path, err)
	}
	return &out, nil
}

type Sandbox struct {
	ID       string `json:"id"`
	Template string `json:"template"`
	Status   string `json:"status"`
}

type ExecResult struct {
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
	ExitCode int    `json:"exit_code"`
}

func (c *Client) Create(ctx context.Context, template string, ttl time.Duration) (*Sandbox, error) {
	return do[Sandbox](ctx, c, http.MethodPost, "/v1/sandboxes", map[string]any{
		"template":    template,
		"ttl_seconds": int(ttl.Seconds()), // backstop if your process dies
	})
}

func (c *Client) Exec(ctx context.Context, id, cmd string, timeout time.Duration) (*ExecResult, error) {
	return do[ExecResult](ctx, c, http.MethodPost, "/v1/sandboxes/"+id+"/exec", map[string]any{
		"cmd":             cmd,
		"timeout_seconds": int(timeout.Seconds()), // enforced inside the guest
	})
}

func (c *Client) Delete(ctx context.Context, id string) error {
	_, err := do[struct{}](ctx, c, http.MethodDelete, "/v1/sandboxes/"+id, nil)
	return err
}

// RunOnce shows the teardown trap. Deferring Delete with the SAME ctx that
// just got cancelled means the DELETE never leaves your process, and the
// microVM lives on until its TTL -- a leak that is invisible in tests, where
// nothing ever gets cancelled, and obvious on an invoice.
func RunOnce(ctx context.Context, c *Client, cmd string) (*ExecResult, error) {
	sb, err := c.Create(ctx, "code-interpreter", 10*time.Minute)
	if err != nil {
		return nil, err
	}
	defer func() {
		stop, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		defer cancel()
		_ = c.Delete(stop, sb.ID)
	}()
	return c.Exec(ctx, sb.ID, cmd, time.Minute)
}

That's the blocking path. The streaming path is the one that decides whether a REST-only sandbox is pleasant or miserable from Go, and it's also where the good news is: SSE is just lines over a `net/http` response body, so `bufio.Scanner` is a complete client. No dependency, no framing library, no WebSocket handshake. Two details below are the ones people get wrong in production — the scanner's default 64KiB token limit, and telling a cancelled context apart from a broken connection:

// Additional imports for this file: "bufio", "strings".

// StreamExec consumes POST /v1/sandboxes/{id}/exec/stream -- an SSE endpoint
// emitting stdout / stderr / exit events. Note what is NOT here: no SSE
// library, no WebSocket, no protobuf. net/http plus bufio is a complete SSE
// client, which is quietly the nicest thing about driving a sandbox from Go.
func (c *Client) StreamExec(
	ctx context.Context,
	id, cmd string,
	onChunk func(stream, text string),
) (int, error) {
	body, err := json.Marshal(map[string]any{"cmd": cmd, "timeout_seconds": 600})
	if err != nil {
		return -1, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.BaseURL+"/v1/sandboxes/"+id+"/exec/stream", bytes.NewReader(body))
	if err != nil {
		return -1, err
	}
	req.Header.Set("Authorization", "Bearer "+c.APIKey)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "text/event-stream")

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return -1, fmt.Errorf("stream exec: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return -1, &APIError{StatusCode: resp.StatusCode, Code: "stream_failed"}
	}

	sc := bufio.NewScanner(resp.Body)
	// bufio.Scanner tops out at 64KiB per token by default. A bundler that
	// prints one enormous line will otherwise end your stream with
	// bufio.ErrTooLong at exactly the moment you wanted the output most.
	sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)

	event, exit := "", -1
	for sc.Scan() {
		line := sc.Text()
		switch {
		case line == "": // a blank line terminates one SSE event
			event = ""
		case strings.HasPrefix(line, "event:"):
			event = strings.TrimSpace(line[len("event:"):])
		case strings.HasPrefix(line, "data:"):
			data := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")
			switch event {
			case "stdout", "stderr":
				var p struct {
					Text string `json:"text"`
				}
				if json.Unmarshal([]byte(data), &p) == nil {
					onChunk(event, p.Text)
				}
			case "exit":
				// The exit code arrives out-of-band as its own event, so you
				// never sniff it out of the text -- which matters, because a
				// test runner will eventually print your sentinel verbatim.
				var p struct {
					ExitCode int `json:"exit_code"`
				}
				if json.Unmarshal([]byte(data), &p) == nil {
					exit = p.ExitCode
				}
			}
		}
	}
	if err := sc.Err(); err != nil {
		// A cancelled context surfaces here as a read error, not a clean EOF.
		// Distinguish them, or you will page someone about "connection reset"
		// every time a user presses stop.
		if ctx.Err() != nil {
			return exit, fmt.Errorf("stream cancelled: %w", ctx.Err())
		}
		return exit, fmt.Errorf("stream read: %w", err)
	}
	if exit < 0 {
		// Stream ended without an exit event: the far side died, a proxy cut an
		// idle connection, or the guest was reaped. Do not report success.
		return exit, fmt.Errorf("stream ended before exit event: %w", io.ErrUnexpectedEOF)
	}
	return exit, nil
}

// And the level-two cancellation the API can't give you for free: when the
// context dies, actively delete the sandbox instead of trusting the far side
// to notice your socket went away.
func (c *Client) killOnCancel(ctx context.Context, id string) (stop func()) {
	done := make(chan struct{})
	go func() {
		select {
		case <-ctx.Done():
			bg, cancel := context.WithTimeout(context.Background(), 10*time.Second)
			defer cancel()
			_ = c.Delete(bg, id)
		case <-done:
		}
	}()
	return func() { close(done) }
}

For comparison, the same three operations in PandaStack's Python SDK, which is the honest measure of what you're writing by hand and what you're getting in exchange — namely a client that fits your agent exactly, has no dependencies, and whose cancellation semantics you can actually explain in a design review:

# Everything the ~200 lines of Go above spell out -- lifecycle, typed result,
# timeouts, cleanup -- is a default here. That gap is the tax on being a Go
# shop in an AI ecosystem whose defaults are Python and TypeScript. It is a
# real tax, and it is also about one afternoon, which is worth knowing before
# you pick a vendor on SDK availability alone.

from pandastack import Sandbox

sbx = Sandbox.create(template="code-interpreter", ttl_seconds=600)
try:
    r = sbx.exec("pytest -q", timeout_seconds=60)

    # Branch on exit_code, never on "is stderr empty". Plenty of well-behaved
    # tools write to stderr and exit 0; plenty of broken ones exit 1 silently.
    if r.exit_code != 0:
        # stderr is the highest-value thing a sandbox gives an agent: it's the
        # correction signal. Truncate before it reaches the context window.
        feedback = r.stderr[-4000:]
finally:
    sbx.kill()

# The TypeScript SDK is the same shape:
#   import { Sandbox } from "@pandastack/sdk";
#
# Both speak the same REST surface the Go client above targets, which is the
# useful property: you can prototype the agent loop in Python, confirm the
# semantics, then port to Go against an API you have already exercised.

The decision guide

  • Start by writing the curl script, not the Go client. Create, exec, stream, delete, and one deliberately broken request. If that's pleasant, the Go client is an afternoon; if it isn't, no SDK on any other language would have saved you either — you'd just have found out later.
  • Weight the HTTP and streaming surface far more heavily than the SDK matrix. "Has a Go SDK" is a nice-to-have that may be a thin generated wrapper; "has documented SSE streaming with an out-of-band exit code" is a property you'll feel every single day.
  • Pick a focused agent-sandbox product (E2B, Runloop, PandaStack) if the sandbox is the feature and you want the 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 and first-class copy-on-write forking for branching agent state, and you want the option to own the substrate — accepting that Go means the REST API, vCPU/RAM are fixed at snapshot-bake time, and self-hosting is real work.
  • Pick Fly.io Machines if durable per-agent state is the hard requirement and you were always going to write an HTTP client anyway — you lose the agent-shaped conveniences and gain a general primitive you can shape freely.
  • Pick Northflank if you want sandboxes, services, jobs, and databases on one control plane inside your own cloud account, and the breadth is the point rather than overhead.
  • Pick Modal if the real workload is GPU or batch compute with a sandbox attached, you're willing to adopt a Python programming model alongside your Go service, and gVisor's boundary satisfies your threat model after you've actually read about it.
  • Pick Daytona if your agents work inside longer-lived, dev-environment-shaped workspaces rather than firing a thousand disposable creates per hour.
  • Pick Vercel Sandbox only if you also have a Next.js app doing the orchestration — the value is ecosystem adjacency, and a Go binary isn't adjacent.
  • Pick self-hosted gVisor, Kata, or raw Firecracker with firecracker-go-sdk if the substrate is strategic at your scale and you can staff a team for it. The Go-native tooling makes the prototype delightful and the production system expensive; reread that sentence before committing.
  • Pick plain os/exec 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 in exchange for protection against a threat that isn't in your model.
  • Pick a container as your only boundary for model-generated code if you must ship this quarter — then put a VM around the fleet, and be precise with your security reviewer about which one you actually have.

The bottom line

There's no best sandbox API for Go agents — there's a best fit for the shape of your loop and the honesty of your threat model. The serious options broadly agree on the thing that matters most: code a model wrote needs a real boundary, and a shared kernel isn't one. Where they diverge, for you specifically, is a set of questions a Python-first roundup never asks. Does cancellation reach the guest or only the socket? Can you stream with `bufio` or do you need someone's WebSocket client? Does an error arrive as a code you can switch on or as prose you have to match? Is the exit code out-of-band? Those answers are all in the REST reference, and they're all checkable before you write a line of Go.

So work backwards from the capability your agent leans on hardest. If it's per-turn create cost, measure create latency on your own template in your own region rather than trusting anyone's headline, mine included. If it's branching — try five fixes, keep the one whose tests pass — check fork semantics explicitly, because a snapshot you restore later is a backup and a fork of a live machine is a branch, and plenty of platforms have one without the other. Shortlist two, put both behind the same small Go interface so swapping is a one-line change, and let a week of real agent traffic decide. PandaStack's bet, for the record, is an Apache-2.0 Firecracker core with a deliberately small REST surface — 179ms p50 create, 400–750ms same-host forks, SSE streaming exec, first-class filesystem, TTLs that reap — that you can run end to end on your own hardware, and that takes about 200 lines of Go to drive. If that matches your loop, benchmark it against the field and keep us honest. If it doesn't, one of the others above genuinely fits you better, and I'd rather you use that than churn off mine in six months.

Frequently asked questions

Do these sandbox platforms have official Go SDKs?

I'm deliberately not answering that in a blog post, because it's the single fastest-moving fact in this whole comparison and an outdated claim here would cost you more than it saved. Check three places yourself and note the date: pkg.go.dev for a published module, the vendor's GitHub organisation for a repo that isn't archived, and the vendor's own docs for whether Go appears as a supported language or only as an OpenAPI spec you can generate from. Then check the last commit date and whether the client tracks the current API version, because a stale Go client is often worse than no Go client — it looks official and lags by two releases. The reframe I'd suggest: treat SDK availability as a convenience factor rather than a gate. In Go, a competent client over a clean REST surface is roughly 200 lines of stdlib code, so the quality of the HTTP and streaming surface should carry far more weight in your decision than whether someone has already wrapped it.

How do I actually cancel a running command inside the sandbox from Go?

You want three layers, and most integrations only have one. First, a per-exec timeout that the server enforces inside the guest — a client-side deadline that returns control to your goroutine while the microVM keeps burning CPU on the model's infinite loop is not a timeout, it's a leak with good manners. Second, wire `ctx.Done()` to an explicit kill or delete call in a goroutine, because cancelling a context cancels your HTTP request and nothing else; the far side has no idea your socket went away and may happily continue for as long as its own limits allow. Third, put a TTL on the sandbox at create time, so that if your process is SIGKILLed, OOM-killed, or evicted between create and cleanup, the machine reaps itself rather than billing quietly. One Go-specific trap worth calling out: don't reuse the cancelled context for teardown — `defer c.Delete(ctx, id)` with the same context that just got cancelled means the DELETE never leaves your process. Use `context.WithoutCancel` or a fresh background context with a short timeout, and then go test it by cancelling a turn mid-build and checking whether the sandbox is actually gone.

Should I write my own Go client or use a generated OpenAPI client?

For a sandbox API specifically, hand-rolling is usually the better trade, which is not advice I'd give for a large cloud provider's API. The surface you actually need is small — create, exec, stream, filesystem read/write, delete — maybe eight methods, and you can write them in an afternoon with only the standard library. What you get in return is the part generated clients handle worst: cancellation semantics you chose, typed errors shaped like your agent's failure modes rather than the spec's, a session type you can stash in your orchestration state, and a streaming implementation that hands you an exit code instead of a channel of raw bytes. Generated clients also tend to make every field a pointer to model optionality faithfully, which is correct and deeply unpleasant to write agent code against. The exception is when the vendor's spec is genuinely large and you need broad coverage of resources you can't predict — then generate the transport layer and hand-write a thin ergonomic wrapper on top, which is the pattern most people converge on anyway.

Do I need a microVM, or is a container enough for running model-generated code?

It depends on who wrote the code, and the honest test is whether you'd be comfortable describing your setup precisely to a security reviewer. A container is namespaces and cgroups around a process on the host's one shared kernel, so the entire Linux syscall interface is exposed to whatever runs inside. Hardening helps materially — seccomp profiles, dropped capabilities, rootless, read-only rootfs, no host mounts — but none of it changes the fact that a kernel bug becomes a host compromise. For code you wrote and reviewed, that's a perfectly reasonable posture. For arbitrary code a model generated, possibly influenced by a prompt injection in a document your agent read thirty seconds ago, a hardware-virtualized microVM is the right default: each sandbox gets its own guest kernel, so your exposed surface becomes a small, heavily-audited VMM instead. gVisor is a meaningful middle rung, and Kata gives you VM isolation behind a container interface if your orchestration is already container-shaped. Don't read 'microVM' as 'unbreakable' either — VMMs and KVM have both had bugs — so layer per-sandbox egress control on top regardless, because a perfectly isolated VM with unrestricted internet can still exfiltrate everything you put in it.

How do I keep a sandbox alive across an agent's tool calls in Go?

Create one sandbox per task rather than per tool call, and carry the ID in whatever struct already represents the agent's run — the same place you keep the conversation history. Fresh-per-call is the safer default and the right choice for stateless one-shot tools, but for an agent iterating on a repository it's usually the wrong economics: if every turn re-clones the repo and reinstalls dependencies, setup becomes the dominant cost of the entire loop in both latency and spend. Two Go-specific details matter here. If your tool handlers run concurrently — parallel tool calls, a `errgroup` fan-out — either serialise access to the sandbox with a mutex or accept that two commands are sharing a filesystem, because a shared machine has shared state and the model will absolutely have two turns write to the same path. And be deliberate about what 'stateful' means for the platform you chose: filesystem persistence, where the machine survives between calls, is a different feature from process persistence, where a REPL keeps variables alive Jupyter-style. Most coding agents want the first plus explicit file passing, which is also easier to debug. Always set a TTL alongside the session so a crashed run reaps itself.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.