
Agent Sandboxes on AWS: Implementing Programmatic Tool Calling with Firecracker on Lambda MicroVMs
Per-session Firecracker microVMs through a developer API with JSON-RPC 2.0 over WebSockets for inline tool calling.
Series: Agent Sandboxes on AWS (5 articles)
- …
- 4Agent Sandboxes on AWS: Implementing Programmatic Tool Calling with Firecracker on Lambda MicroVMs This article
Contents
- Introduction
- Where Lambda MicroVMs Fit
- Key Capabilities
- Core Concepts: Images and MicroVMs
- The API Surface: Four Calls for the Sandbox Path
- The Snapshot Lifecycle
- Lifecycle Management, Built In
- Architecture and Flow
- Transport: JSON-RPC 2.0 over WebSockets
- Inside the MicroVM: the Sandbox
- Networking, Security, and Best Practices
- Comparison: MicroVMs vs the EKS Runtimes
- Considerations
- Conclusion
Introduction
This post is part of the Agent Sandboxes on AWS series. Start there for the fundamentals we build on here: what an agent sandbox is (isolated, stateful, ephemeral, with a stable identity), the six use cases, the four-layer stack (Compute → Isolation & Scheduling → Lifecycle → Workload), and how to choose an approach. This post takes one concrete workload — an agent that writes and runs untrusted Python with inline tool calling — all the way down the AWS Lambda MicroVMs path.
A one-line recap of the pattern we're implementing: in programmatic tool calling (PTC), the model is invoked once and writes a program that calls tools as ordinary functions; the program runs in a sandbox, and each tool call is relayed back out to the orchestrator, which holds the credentials and makes the real call. The sandbox needs bidirectional communication, a long-enough lifetime to finish the program, and strong isolation — because the code inside it is model-generated and untrusted.
The sibling post in this series, Secure Agent Sandboxes on EKS , builds exactly this pattern on Amazon EKS with AWS Fargate, gVisor, and Kata. This post keeps the same agent loop and the same restricted-execution engine, and changes three things at the platform level:
- The compute substrate becomes AWS Lambda MicroVMs — Firecracker microVMs launched through an API, with no cluster or nodes to run.
- The transport becomes JSON-RPC 2.0 over WebSockets, riding the MicroVM's managed authenticated ingress instead of raw TCP.
- Lifecycle management is built in — snapshot-resume, suspend/resume, and a hard TTL come from the platform, so there's no warm-pool controller to operate.
The core logic — the code generator and the restricted-builtins execution engine — is unchanged from the EKS implementation; what changes is where it runs, how the pieces talk, and (as the companion post covers) how the sandbox process is structured around that engine. That's the point of the series: one primitive, several paths.
This post focuses on the platform and the end-to-end architecture — how you use Lambda MicroVMs to run a per-session agent sandbox. The design inside the MicroVM — the two-process privilege separation, the internal transport framing, and the seccomp/user-namespace hardening — is its own subject, covered in the companion post Isolating Untrusted Code with a Two-Process Sandbox . That design is portable (generic Linux), so it lives separately; here we reference it where it matters and keep the focus on the MicroVM platform.
Where Lambda MicroVMs Fit
On the series' shared-responsibility spectrum, Lambda MicroVMs sit at the developer-API rung. AWS owns Compute, Isolation & Scheduling, and Lifecycle; you own only the Workload — your sandbox image and the orchestrator that drives it. There is no cluster, no node group, no isolation runtime to install, and no lifecycle controller to run. You call an API, you get an isolated VM with a stable HTTPS endpoint, and you tear it down when you're done.
For the full landscape and where this sits relative to Amazon Bedrock AgentCore and self-managed Amazon EKS, see the pillar's shared-responsibility section .
Key Capabilities
Lambda MicroVMs is a serverless primitive inside AWS Lambda that gives each session its own isolated, stateful virtual machine. It's powered by Firecracker — the same virtualization engine behind Lambda — so it inherits that operational maturity. Three properties matter for an agent sandbox:
- VM-level isolation. Each session is its own Firecracker microVM with a dedicated kernel, memory, disk, and network — up to 32 GB of RAM and 16 vCPUs, with full root inside. Untrusted code from one session is contained in its own VM, and many run side by side sharing nothing.
- Snapshot start and resume. At build time, Lambda starts your app and snapshots the running process; every launch resumes from that snapshot instead of cold-booting, so your server is already running when the MicroVM comes up. Idle MicroVMs suspend and resume with full state intact.
- Stateful, with no infrastructure. Memory, disk, and processes persist across the session. You build from a standard Dockerfile, run up to eight hours, and there are no clusters, VPCs, or capacity planning to manage. While a MicroVM is suspended you pay for storage, not compute.
The combination is what's new: VM isolation, near-instant start, and state persistence in a single serverless primitive.
Core Concepts: Images and MicroVMs
- A MicroVM Image is a reusable template of your code. You provide a Dockerfile and sources (zipped in S3), and Lambda builds it, starts your process, and snapshots the running state. Every instance launches with your server already running.
- A MicroVM is an individual instance launched on demand from an image. Each gets its own dedicated HTTPS endpoint and a short-lived auth token, fully isolated from the others. It suspends with state preserved and resumes on the next request.
- A Network Connector is an AWS-managed resource you reference or create your own custom ones. Connectors are pre-provisioned ARNs you attach at launch to control traffic: an ingress connector gates the inbound path to your endpoint, and an egress connector governs outbound access (for example, public internet). You pass their ARNs to
RunMicrovm.
For a per-session sandbox, this maps cleanly: build one sandbox image, then launch one MicroVM per agent session — attaching the ingress and egress connectors it needs — and terminate it when the session ends, or use Idle timeouts for Lambda to suspend/terminate your sandboxes automatically.
The API Surface: Four Calls for the Sandbox Path
The service exposes a couple of dozen operations in total — image versioning, suspend/resume, shell access, tagging, and more — but the per-session sandbox only needs four of them, covering build → launch → authenticate → tear down (the boto3 service is
lambda-microvms, but note the IAM actions use the lambda: prefix — e.g. lambda:RunMicrovm, plus lambda:PassNetworkConnector to attach the connectors):| Call | Purpose |
|---|---|
| CreateMicrovmImage | Build and snapshot your app from a Dockerfile + sources in S3. Returns an image ARN. |
| RunMicrovm | Launch an instance from the image. Returns a microvmId and an endpoint (a bare host — build wss://{endpoint} for WebSockets). |
| CreateMicrovmAuthToken | Mint a short-lived inbound token (scoped to allowed ports) to authenticate requests to the endpoint. |
| TerminateMicrovm | Stop the instance — or let it self-terminate after idle or the maximum duration. |
Beyond these four, a few operations are worth knowing about even though the basic sandbox path doesn't call them directly:
SuspendMicrovm and ResumeMicrovm give you programmatic control over the suspend/resume the idle policy otherwise handles automatically; CreateMicrovmShellAuthToken mints a token for interactive shell access (which requires the SHELL_INGRESS connector); GetMicrovm and ListMicrovms cover status and inventory; and a full set of image, version, and tagging operations manages the build side.A small lifecycle wrapper is the only genuinely new component compared with a Kubernetes-based sandbox — it's the analog of a per-session Job manager, but instead of creating a pod it calls
RunMicrovm and mints an inbound token. Here's a minimal sample version (illustrative, not production-hardened); launch() returns everything the orchestrator needs to open a WebSocket, and terminate() enforces terminate-never-reuse:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import boto3
from dataclasses import dataclass
REGION = "us-west-2"
SANDBOX_PORT = 8080
# Managed connectors: inbound endpoint routing needs ALL_INGRESS; public egress uses INTERNET_EGRESS.
ALL_INGRESS = f"arn:aws:lambda:{REGION}:aws:network-connector:aws-network-connector:ALL_INGRESS"
INTERNET_EGRESS = f"arn:aws:lambda:{REGION}:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
class MicroVMHandle:
microvm_id: str
endpoint: str # bare host from RunMicrovm — build wss://{endpoint}
auth_token: str # value of authToken["X-aws-proxy-auth"]
port: int = SANDBOX_PORT
class MicroVMManager:
def __init__(self, image_arn, execution_role_arn=None, region=REGION):
self.image_arn = image_arn
self.execution_role_arn = execution_role_arn
self._client = boto3.client("lambda-microvms", region_name=region)
def launch(self) -> MicroVMHandle:
run = self._client.run_microvm(
imageIdentifier=self.image_arn,
idlePolicy={"autoResumeEnabled": True,
"maxIdleDurationSeconds": 900,
"suspendedDurationSeconds": 300},
maximumDurationInSeconds=14400, # hard TTL, <= 28800 (8h)
ingressNetworkConnectors=[ALL_INGRESS], # required for inbound
egressNetworkConnectors=[INTERNET_EGRESS],
**({"executionRoleArn": self.execution_role_arn}
if self.execution_role_arn else {}),
)
# RunMicrovm returns the endpoint directly; the platform holds the first
# inbound request until the /run hook returns 200, so polling is optional.
token = self._client.create_microvm_auth_token(
microvmIdentifier=run["microvmId"],
expirationInMinutes=30,
allowedPorts=[{"port": SANDBOX_PORT}],
)["authToken"]["X-aws-proxy-auth"]
return MicroVMHandle(run["microvmId"], run["endpoint"], token)
def terminate(self, microvm_id) -> None:
self._client.terminate_microvm(microvmIdentifier=microvm_id) # never reuseThe Snapshot Lifecycle
The snapshot is the mechanism behind the near-instant start. It runs in two phases:
- At build time, once: Lambda runs your Dockerfile, starts your app, and snapshots the running memory and disk. That snapshot becomes the launch baseline.
- At run time, every launch: the MicroVM resumes from the snapshot instead of cold-booting, so the app is already running. It serves traffic and accumulates state; on idle it suspends with state preserved; it resumes automatically on the next request.
A best practice follows directly from how this works: the snapshot captures process state at build time, so anything computed once at startup — random seeds, timestamps, cached credentials, open connections — is frozen into every instance. Do per-instance initialization after resume (on the first request or via a lifecycle hook), not at import time.
Lifecycle Management, Built In
The pillar defines a vocabulary of lifecycle operations — warm pools, checkout/release, TTL leases, suspend/resume, fork/snapshot, and terminate-never-reuse. On the EKS path you assemble those yourself (or with the
agent-sandbox operator). On Lambda MicroVMs they map to platform-native features you configure at launch:| Lifecycle operation | Lambda MicroVMs mechanism |
|---|---|
| Checkout a ready sandbox | RunMicrovm resumes from the snapshot — the app is already running |
| TTL lease | maximumDurationInSeconds (≤ 28800 / 8h) |
| Suspend / resume | idlePolicy (autoResumeEnabled, maxIdleDurationSeconds, suspendedDurationSeconds) |
| Terminate, never reuse | TerminateMicrovm, or self-termination on idle/max duration |
There's no warm-pool controller to run: the snapshot-resume behavior is the warm start, and the idle policy handles suspend/resume. You set the policy once at
RunMicrovm time and the platform owns the rest:1
2
3
4
5
DEFAULT_IDLE_POLICY = {
"autoResumeEnabled": True,
"maxIdleDurationSeconds": 900,
"suspendedDurationSeconds": 300,
}Architecture and Flow
The orchestrator is a small web app that owns the agent loop; the sandbox is a MicroVM running a WebSocket server that brokers for a separate restricted executor. The credentials and every real tool call stay on the orchestrator — the sandbox reaches nothing directly.

The end-to-end path for one session:
- The orchestrator asks the model to generate a program for the user's task.
- In parallel, it calls
RunMicrovmto launch a sandbox andCreateMicrovmAuthTokento mint an inbound token. - It opens an authenticated WebSocket to the MicroVM's endpoint and sends the generated code as a JSON-RPC
executerequest. - As the program runs, each
call_tool(...)inside the sandbox becomes a JSON-RPC request flowing back to the orchestrator, which resolves it against the tool gateway (with its own credentials) and returns the result — mid-execution. - When the program finishes, the sandbox returns the result, and the orchestrator calls
TerminateMicrovm.
As a sequence, those steps look like this — note the parallel launch, the
call_tool inversion loop where the sandbox drives requests back to the orchestrator, and the terminate-never-reuse cleanup:
Transport: JSON-RPC 2.0 over WebSockets
The EKS implementation used newline-delimited JSON over a raw TCP socket. Here the MicroVM provides a managed, authenticated HTTPS/WebSocket endpoint, which makes WebSockets the natural transport — and JSON-RPC 2.0 makes the bidirectional relay first-class, because the sandbox initiating a
call_tool mid-execution is just a request flowing the other direction over the same socket.The contract is symmetric — either side can issue a request, and
id correlates a response to its request:| Direction | Message |
|---|---|
| orchestrator → sandbox | {"id":1,"method":"execute","params":{"code":"..."}} |
| sandbox → orchestrator | {"id":101,"method":"call_tool","params":{"tool":"...","args":{...}}} |
| orchestrator → sandbox | {"id":101,"result":{...}} |
| sandbox → orchestrator | {"id":1,"result":{"stdout":"...","status":"completed"}} |
| either | {"id":N,"error":{"code":-32001,"message":"..."}} |
Application errors use the JSON-RPC server band (
-32000..-32099) — for example -32001 for "executed code raised."One transport detail worth setting explicitly: a maximum message size (1 MiB in this implementation) via the WebSocket's
max_size. The same ceiling is enforced on the internal hop inside the MicroVM, so a message that fits one hop fits the other. Because exceeding a WebSocket's limit closes the connection abruptly, the orchestrator also size-checks a generated program before sending it and returns a clean "program too large" error rather than letting an oversized execute drop the connection. The companion post covers how the limit is shared across both hops.The same JSON-RPC 2.0 contract is reused on a second hop inside the MicroVM — between the WebSocket server and the code executor — which is where the sandbox's privilege separation lives. The next section is the short version; the full design is in the companion post.
Transport: JSON-RPC 2.0 over WebSockets
The EKS implementation used newline-delimited JSON over a raw TCP socket. Here the MicroVM provides a managed, authenticated HTTPS/WebSocket endpoint, which makes WebSockets the natural transport — and JSON-RPC 2.0 makes the bidirectional relay first-class, because the sandbox initiating a
call_tool mid-execution is just a request flowing the other direction over the same socket.The contract is symmetric — either side can issue a request, and
id correlates a response to its request:| Direction | Message |
|---|---|
| orchestrator → sandbox | {"id":1,"method":"execute","params":{"code":"..."}} |
| sandbox → orchestrator | {"id":101,"method":"call_tool","params":{"tool":"...","args":{...}}} |
| orchestrator → sandbox | {"id":101,"result":{...}} |
| sandbox → orchestrator | {"id":1,"result":{"stdout":"...","status":"completed"}} |
| either | {"id":N,"error":{"code":-32001,"message":"..."}} |
Application errors use the JSON-RPC server band (
-32000..-32099) — for example -32001 for "executed code raised."One transport detail worth setting explicitly: a maximum message size (1 MiB in this implementation) via the WebSocket's
max_size. The same ceiling is enforced on the internal hop inside the MicroVM, so a message that fits one hop fits the other. Because exceeding a WebSocket's limit closes the connection abruptly, the orchestrator also size-checks a generated program before sending it and returns a clean "program too large" error rather than letting an oversized execute drop the connection. The companion post covers how the limit is shared across both hops.The same JSON-RPC 2.0 contract is reused on a second hop inside the MicroVM — between the WebSocket server and the code executor — which is where the sandbox's privilege separation lives. The next section is the short version; the full design is in the companion post.
Comparison: MicroVMs vs the EKS Runtimes
This is the delta only — see the secure-sandboxes deep dive for the full Fargate/gVisor/Kata comparison.
| Aspect | Lambda MicroVMs | EKS (Fargate / gVisor / Kata) |
|---|---|---|
| Isolation | Firecracker microVM per session (own kernel) | Task isolation (Fargate) · user-space kernel (gVisor) · microVM per pod (Kata) |
| Start | Snapshot resume — app already running | Warm-pool checkout sub-second; cold start ~3s (gVisor) to ~45s |
| Infrastructure | None — API only | You run the cluster and (for gVisor/Kata) the node AMI |
| Lifecycle | Built in (snapshot, idle policy, TTL) | Self-managed, or via the agent-sandbox operator |
| Transport in this series | JSON-RPC 2.0 over WebSockets | NDJSON over TCP |
| Network | Managed ingress connector; egress public/VPC/none | Kubernetes NetworkPolicy (default-deny) |
Considerations
- The minimal base includes a userland (shell + package manager), a larger static surface than the distroless image on EKS. In practice the runtime hardening already neutralizes most of it — the executor can't
execve(seccomp), reach the network (net=none), or import system modules (restricted builtins) — so a distroless base is incremental defense in depth rather than a fix for an open gap. - The privilege-separated sandbox adds moving parts — a second process, an IPC channel, and seccomp/namespace setup — in exchange for making the microVM the last boundary rather than the only one. That tradeoff, and the full design, is the subject of the companion post, Isolating Untrusted Code with a Two-Process Sandbox ; a simpler in-process executor is possible if you're comfortable relying on the microVM alone.
Conclusion
The takeaway matches the rest of the series: AWS owns Compute, Isolation, and Lifecycle here; you bring only the Workload. By swapping the compute substrate to Lambda MicroVMs and the transport to JSON-RPC over WebSockets, the same agent loop and restricted-execution sandbox from the EKS posts run with no cluster, no nodes, and no lifecycle controller — just four API calls and a WebSocket.
For the in-VM sandbox design that this post keeps deferring to — the two-process privilege separation, the internal transport, and the seccomp/user-namespace hardening, with code — see the companion post, Isolating Untrusted Code with a Two-Process Sandbox .
For the concepts behind all of this, start with the Agent Sandboxes on AWS pillar. For the Kubernetes path with three isolation runtimes, see Secure Agent Sandboxes on EKS , and for warm pools with the
agent-sandbox operator, see Serverless AI Agent Sandboxes on EKS Fargate .Series: Agent Sandboxes on AWS (5 articles)
- …
- 4Agent Sandboxes on AWS: Implementing Programmatic Tool Calling with Firecracker on Lambda MicroVMs This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article