Configure a sandbox¶
Note
Experimental: this can change or be removed in a minor release of this provider. See Stable and experimental features.
A SandboxToolset gives an agent
a sandbox with no network access and none of Airflow’s credentials, and deletes it
when the agent run ends. To change that:
To let the sandbox reach the network or give it environment variables, pass a
SandboxSpec(Control network access, Give code in the sandbox a credential).To choose where it runs, its image and its size, configure the backend (Sandbox backends).
To keep a file the agent wrote, copy it to object storage before the sandbox is deleted with
exports(Get a file out).To keep the sandbox itself after the run, for a later task or a review, create it in a task and pass it to the agent with
attach_to(Share a sandbox between tasks).
If the Dag already knows which commands to run, you don’t need an agent: drive the
backend from a @task (Get a file out).
Control network access¶
A sandbox has no outbound network by default, including DNS, and the model is told
so in the run_command tool description:
This sandbox has NO network access, including DNS, so installing packages or
downloading anything will fail. Work with what the image already has.
To let it reach specific hosts, list them in allow_egress_to. On Modal a hostname
list has to be accepted explicitly, for reasons below:
from airflow.providers.common.ai.sandbox import ModalSandboxBackend, SandboxSpec
from airflow.providers.common.ai.toolsets import SandboxToolset
SandboxToolset(
ModalSandboxBackend(egress_enforcement="sni"),
spec=SandboxSpec(allow_egress_to=["pypi.org", "files.pythonhosted.org"]),
)
allow_egress_to_cidrs lists IPv4 ranges instead. Both lists narrow the default
block_network=True, so don’t combine them with block_network=False, which
opens outbound access. Backends differ in what they can enforce:
Backend |
Default (no network) |
|
|
|
|---|---|---|---|---|
Modal |
Yes |
With |
Yes, any port |
Yes; metadata endpoint and private ranges stay blocked |
|
With |
With |
No |
Yes; egress is whatever the host policy allows |
OpenSandbox |
Yes, if the egress sidecar runs in |
Yes, same condition |
No |
Yes |
OpenShell |
Yes |
Yes, port 443 only |
No |
No |
Boat |
No |
No |
No |
Yes |
Islo |
Yes |
No |
No |
Yes |
A setting the backend cannot enforce fails the task on the agent’s first tool call,
when the toolset creates the sandbox, and the message says what to change. A bare
SandboxSpec() on sbx, where egress is a host-wide policy the backend cannot
see, fails without host_network_policy="deny-all":
SandboxTerminalError: SandboxSpec asks for no network egress, but this backend
cannot enforce that per sandbox and the host policy has not been declared. Run
'sbx policy init deny-all' on the worker host and pass
host_network_policy='deny-all', or pass SandboxSpec(block_network=False) to
acknowledge that egress is open.
Modal checks a hostname list against the name in the TLS handshake. That lets TLS on
port 443 through to listed hosts, but DNS stays open for every name, and a host that
shares a TLS endpoint with a listed one can be reached. Without
egress_enforcement="sni" the backend refuses the list and says so:
SandboxTerminalError: SandboxSpec names a hostname allowlist, which Modal can only
enforce by matching the TLS SNI: it allows TLS on port 443 to those hosts, but
leaves DNS open for every hostname and cannot stop a host that shares a TLS
endpoint with an allowed one from being reached. Pass
ModalSandboxBackend(egress_enforcement='sni') to accept that, use
allow_egress_to_cidrs for a destination with a fixed address, which Modal
enforces at the address layer, or use SandboxSpec(block_network=True) with no
allowlist, which Modal enforces exactly and which also blocks DNS.
What each backend enforces, and how, is under Modal, OpenSandbox, OpenShell, Boat, Islo and sbx.
Install packages¶
With no network, pip install inside the sandbox retries five times on
Temporary failure in name resolution and then returns to the model:
[stderr]
WARNING: Retrying (Retry(total=4, ...)) after connection broken by ...: Temporary failure in name resolution')': /simple/requests/
...
ERROR: Could not find a version that satisfies the requirement requests (from versions: none)
ERROR: No matching distribution found for requests
[exit code: 1]
Install what the agent needs into the image instead:
import modal
ModalSandboxBackend(
image=modal.Image.from_registry("python:3.12-slim").pip_install("pandas", "pyarrow"),
)
Then say in the agent’s instructions which packages it has. The tool description tells the model whether it has network access, but not what the image contains.
On a backend that supports hostname lists (see the table), you can allow pypi.org
and files.pythonhosted.org instead, as in the example above; on Modal, only if the
hostname-list caveats are acceptable.
Size the sandbox and its timeouts¶
Size the sandbox on the backend. A Modal sandbox without cpu requests 0.125 of a
CPU core, so set cpu for real work. Modal treats memory as a request: code in
the sandbox can allocate more. Other backends size differently; see
What differs between the backends.
A command gets 60 seconds (default_command_timeout) unless the model asks for
more, and never more than max_command_timeout (300 seconds).
The sandbox itself is deleted when the backend’s lifetime runs out: sandbox_timeout
on Modal, 3600 seconds by default. The clock runs while the model thinks between tool
calls, so set it for the whole agent run.
Modal’s idle_timeout is off by default. The model generating its next step looks
idle to Modal, and an idle timeout would delete the sandbox and the agent’s files.
Give two sandboxes to one agent¶
Tool names must be unique within an agent. Two SandboxToolset instances both
define run_command, so the second needs a tool_prefix:
toolsets = [
SandboxToolset(
SbxSandboxBackend(image="python:3.12-slim", host_network_policy="deny-all"),
tool_prefix="py",
),
SandboxToolset(
SbxSandboxBackend(image="node:22-slim", host_network_policy="deny-all"),
tool_prefix="node",
),
]
Without it, the run fails on the first model request with:
UserError: SandboxToolset 'sandbox-sbx' defines a tool whose name conflicts with
existing tool from SandboxToolset 'sandbox-sbx': 'run_command'. Rename the tool or
wrap the toolset in a `PrefixedToolset` to avoid name conflicts.
Tell the model in its instructions which sandbox is for what.
Give code in the sandbox a credential¶
Nothing from Airflow reaches the sandbox unless you put it in SandboxSpec.env: no
connection, variable or worker environment variable. The credential that creates
the sandbox, such as your Modal token, stays in the task.
Warning
The model can read anything in env. Code it writes can print the environment,
and tool results go into the model’s context, from where they can reach the
agent’s output and any traces you record. Tool output is passed through Airflow’s
secret masker, which hides values Airflow knows are secrets, but not an encoded
or altered copy. Give the sandbox a credential scoped to this one job, or none.
If the model only needs to query a system, not hold its key, use a
SQLToolset or
HookToolset. They run the hook in
the task and return only the result.
SandboxSpec is not templated, and it is built when the Dag file is parsed. To pass
a secret from a connection or a secrets backend, create the sandbox in a task, where
the lookup runs at run time, and attach the agent to it, which needs Modal
(Share a sandbox between tasks). Where you can, use the secret in that task to fetch the data,
write the data into the sandbox, and give the sandbox no secret at all.
Without an agent, the same task does the whole job. In outline, letting the sandbox reach only the API’s address:
@task
def run_with_credential():
from airflow.providers.common.ai.sandbox import ModalSandboxBackend, SandboxSpec
from airflow.providers.common.compat.sdk import BaseHook
token = BaseHook.get_connection("my_api").password
backend = ModalSandboxBackend()
sandbox = backend.create(
spec=SandboxSpec(env={"API_TOKEN": token}, allow_egress_to_cidrs=["203.0.113.10/32"])
)
try:
... # write_file, run_command, read_file
finally:
backend.destroy(sandbox)
Values must be strings; convert them with str() yourself. Modal refuses anything
else before creating the sandbox:
SandboxTerminalError: SandboxSpec.env must map strings to strings; these entries
do not: ['PORT']. Convert the value at the Dag level, e.g. str(port), so what
reaches the sandbox is what you meant.
Get a file out¶
The agent’s answer reaches XCom through output_type, like any other agent output.
A file the agent builds cannot come back that way: read_file returns text, so a
parquet file or an image is corrupted, and the model sees at most 50 KiB of any file
(max_output_bytes).
Name the file in exports instead, with where it should go. When the run ends, the
toolset copies it out of the sandbox and then deletes the sandbox. The copy streams
through the worker, so neither the model’s context nor XCom carries the file:
# Templated per run. Point it at ``s3://`` or ``gs://`` and pass ``export_conn_id``
# in a real deployment; ``file://`` keeps the example runnable on a laptop.
STAGING_URI = "file:///tmp/airflow-sandbox-example/{{ run_id }}/staging.csv"
@dag(
schedule=None,
start_date=datetime(2024, 1, 1, tzinfo=UTC),
catchup=False,
tags=["example", "sandbox"],
)
def example_sandbox_agent_export():
"""Have an agent normalize a vendor file, export the result, and load it downstream."""
normalize = AgentOperator(
task_id="normalize",
prompt=(
"Here is a sample of this month's vendor export:\n\n"
f"{VENDOR_SAMPLE}\n"
"Write it to a file, then write a Python script that turns it into staging.csv with "
"the columns order_id, customer_name, ordered_at (ISO date), amount_usd (decimal) "
"and country_code. Run the script and fix it until every row parses, then report "
"the column mapping you settled on."
),
system_prompt=(
"You have a sandbox with Python 3.12 and the standard library, no network, and "
"an empty working directory. Read tracebacks and fix the code rather than guessing."
),
llm_conn_id="pydanticai_default",
output_type=ColumnMapping,
toolsets=[
SandboxToolset(
SbxSandboxBackend(host_network_policy="deny-all"),
# Copied out before the sandbox is destroyed. If the agent never wrote
# staging.csv, the task fails rather than leaving ``load`` to find nothing.
exports={"staging.csv": STAGING_URI},
),
],
)
@task
def load(staging_uri: str) -> int:
with ObjectStoragePath(staging_uri).open() as staging:
return sum(1 for _ in staging) - 1
normalize >> load(STAGING_URI)
With ModalSandboxBackend() in place of sbx, the normalize task log shows
the export, and load returns 3:
Exported staging.csv from sandbox sb-01M5ZKK8AJR1PG8Z6012AZW7S4 (172B)
Published file:///tmp/airflow-sandbox-example/manual__2026-10-27T21:17:09.931899+00:00/staging.csv
The destination is templated, so each run writes its own file. It can be anything
ObjectStoragePath opens, with credentials from
export_conn_id. Files up to max_export_bytes (1 GiB) are copied.
Each file is first copied to a .partial name next to its destination. If a named
file is missing, is not a regular file, is over the limit, or cannot be copied, the
task fails and no destination changes. Only when every copy has succeeded are the
files moved into place; if a move fails, the task fails and the error lists the files
already in place. A run with no sandbox left at the end, because it never called a
tool or a timeout deleted the sandbox, fails with:
SandboxTerminalError: The run ended with no sandbox standing, so none of the files it was to export exist: 'staging.csv'.
A run that fails exports nothing, so a downstream task with
trigger_rule=TriggerRule.ALL_DONE may find a file from an earlier try.
When the Dag already knows the job, skip the agent. A @task writes the input in
with write_file, runs the command, and reads the result out with read_file
under a byte limit you choose. The full Dag is example_sandbox_task_artifact in
example_sandbox_toolset.py:
@task
def convert(input_uri: str, output_uri: str) -> str:
# Task-only dependencies stay out of the Dag-parsing process.
import modal
from airflow.providers.common.ai.sandbox import ModalSandboxBackend, SandboxSpec
source = ObjectStoragePath(input_uri).read_bytes()
backend = ModalSandboxBackend(
image=modal.Image.from_registry("python:3.12-slim").pip_install("pandas", "pyarrow"),
)
# No egress: the input arrives through write_file and the output leaves
# through read_file, so the sandbox needs no network and no credentials.
sandbox = backend.create(spec=SandboxSpec(block_network=True))
try:
backend.write_file(sandbox, "/workspace/orders.csv", source)
backend.write_file(sandbox, "/workspace/convert.py", CONVERT_SCRIPT.encode())
result = backend.run_command(
sandbox,
"python /workspace/convert.py /workspace/orders.csv /workspace/orders.parquet",
timeout=300,
max_output_bytes=64 * 1024,
)
if result.exit_code:
raise RuntimeError(f"Conversion failed ({result.exit_code}): {result.stderr}")
# The budget here is what the worker can hold, not a model's context.
# Above it read_file raises SandboxFileTooLargeError rather than truncating.
parquet = backend.read_file(sandbox, "/workspace/orders.parquet", max_bytes=256 * MIB)
finally:
# A toolset does this for you at the end of the run. Driving the backend
# yourself means owning teardown, including when the block above raises.
backend.destroy(sandbox)
target = ObjectStoragePath(output_uri)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(parquet)
# Hand the location downstream, not the bytes: XCom is the wrong place for
# a file of the size this pattern exists to handle.
return str(target)
What happens when something fails¶
The toolset creates the sandbox on the first tool call and deletes it when the run
ends. Files persist between calls in the same run; shell variables do not, because
each run_command starts a new shell.
A command that fails goes back to the model, which can fix it. This is what the model
saw on Modal for a command that printed a line and exited with 3, then one that ran
past a two-second timeout, then a cat of a file written before both:
[stdout]
partial
[exit code: 3]
(no output)
[timed out after 2s]
[stdout]
kept across calls
The sandbox and its files survive the timeout. A backend that can only stop a command by deleting the sandbox tells the model its earlier files are gone, and the next call gets a new sandbox.
Errors in the sandbox, such as a missing path, a permission error or a file over the read limit, go back to the model as a retry prompt. Errors the model cannot fix fail the task, and Airflow’s task retries take over: rejected credentials, a sandbox that has passed its lifetime mid-run, and any error while creating the sandbox.
Retries, durable runs and review¶
When the toolset creates its own sandbox, a task retry gets a new, empty one, and an
attached sandbox still has the earlier attempt’s files. AgentOperator refuses the
two features
that would assume otherwise, when the Dag is parsed. It cannot see a toolset built per
run from a callable, so check that case yourself. durable=True is refused with any
SandboxToolset:
ValueError: durable=True cannot be used with a SandboxToolset: cached tool results
would be replayed without touching the sandbox, so the workspace would not match
the transcript. Drop durable=True, or move the sandbox work into its own task.
enable_hitl_review=True is refused unless every SandboxToolset is attached to a
sandbox another task created:
ValueError: enable_hitl_review=True cannot be used with a SandboxToolset that
provisions its own sandbox: a regenerated run would start from an empty sandbox
while its history describes files from the first run. Attach the toolset to a
sandbox another task provisioned (attach_to=...), drop enable_hitl_review=True, or
move the sandbox work into its own task.
With an attached sandbox, the reviewer has to answer before sandbox_timeout, so
set hitl_timeout below what will be left. Per-tool approval works the same way: allowed with an attached sandbox, and a tool
that needs approval fails the task when the toolset created its own sandbox.
Clean up and control cost¶
A sandbox is billed from creation until it is deleted, including the time the model
spends thinking. usage_limits does not cap sandbox cost, because cost_limit
counts only model calls. The backend’s lifetime setting, sandbox_timeout on Modal,
is the most one sandbox can bill for.
If the worker is killed, the toolset never deletes the sandbox, and the backend’s
lifetime is what cleans up. sbx and OpenShell have no lifetime, so their sandboxes
stay until someone removes them. A delete that fails is logged with the sandbox name
and does not fail the task.
On Modal, destroy asks Modal to stop the sandbox and returns without waiting.
Every sandbox carries an airflow_sandbox tag plus the tags you give the
backend, so with ModalSandboxBackend(tags={"dag_id": "my_dag"}) this lists what
that Dag left:
modal.Sandbox.list(app_id=..., tags={"dag_id": "my_dag"})