A Code Mode sandboxed code-execution tool for Agent Development Kit (ADK).
ExecuteCodeTool lets ADK agents write Python code to call tools and read and write files.
Code runs inside a sandboxed container, and tools (and their credentials) are executed on the host.
The base image comes with the stdlib and can be extended with any Python package you want.
The sandboxed container can list, load, and save ADK Artifacts, and files it creates are returned as artifacts.
Inspired by Cloudflare's Code Mode and Anthropic's Code execution with MCP.
- Call ADK tools from sandbox code — imports against the
toolspackage proxy back to the host and run through ADK'sbefore_tool/after_tool/on_errorcallbacks and the plugin manager exactly as direct tool calls would. - Bake any Python package into the image — extend the published base image with anything the model's code needs to
import, no runtimepip installrequired. The sandbox reports what it has installed, so the model is told about it automatically. - Cross-turn persistence via ADK Artifacts —
save_artifact/load_artifact/list_artifactsare auto-injected and route through your configuredArtifactService. Files the code creates or changes are saved as artifacts automatically too. - Tool results saved as artifacts — on by default; every tool's result is persisted as a
code_mode.tool_resultartifact (with optional model-supplied name/description) so hosts can forward outputs and large results stay out of the prompt. Opt out withsave_tool_results_as_artifacts=False. - Bounded stdout/stderr — overflow lands in a session artifact instead of poisoning the prompt.
- Production-ready remote sandbox —
RemoteBackendconnects to an isolated per-turn container over WebSocket, reused across the turn'sexecute_codecalls. Deploy on any cloud platform (Cloud Run, Fargate, ACI, Kubernetes, Fly.io, etc.). - Local development —
UnsafeLocalDockerBackendruns the sandbox against your local Docker daemon for fast iteration. Not for production — see Safety.
| BuiltIn | AgentEngineSandbox | VertexAi | Container | Gke | CodeMode | |
|---|---|---|---|---|---|---|
| Call ADK tools from code | no | no | no | no | no | yes (with limitations) |
| Extra Python packages | no | no (more than stdlib but fixed) | no (more than stdlib but fixed) | yes | yes | yes |
| Variables are stateful | no | yes | yes | no | no | yes (within a turn) |
| Input files | no | yes | yes | no | no | no (use Artifacts) |
| Output files | no | yes | yes | no | no | yes (as Artifacts) |
| Storage | no | yes (via variables) | yes (via variables) | no | no | yes (via ADK Artifacts) |
| Local development version available | no | no | no | yes | yes | yes |
| Bounded stdout/stderr | no | no | no | no | no | yes (max_output_chars) |
pip install adk-code-modeOr with uv:
uv add adk-code-modeFor local development with UnsafeLocalDockerBackend, install the docker extra:
pip install adk-code-mode[docker]Requires Python 3.10+. Local development requires Docker; remote deployment only needs network access to the sandbox URL.
Build an ExecuteCodeTool, give it the tools the sandboxed code may call, and attach it to your agent. Wire release_invocation into after_agent_callback to release the turn's sandbox container when the turn ends. An idle reaper (session_idle_timeout_seconds, default 600) is a backstop.
from google.adk.agents import LlmAgent
from adk_code_mode import ExecuteCodeTool, RemoteBackend
tool = ExecuteCodeTool(
tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
backend=RemoteBackend(
url="https://sandbox-xyz.run.app", # your deployed sandbox URL
token="your-secret-token", # bearer token for auth
),
)
async def _release_sandbox(callback_context):
await tool.release_invocation(callback_context.invocation_id)
root_agent = LlmAgent(
name="assistant",
model="gemini-3.5-flash",
instruction="You are a helpful assistant.",
tools=[tool],
after_agent_callback=[_release_sandbox],
)
UnsafeLocalDockerBackendis not safe for production or multi-tenant use. See Safety.
from adk_code_mode import ExecuteCodeTool, UnsafeLocalDockerBackend
tool = ExecuteCodeTool(
tools=[my_fn_tool, McpToolset(...), OpenAPIToolset(...)],
backend=UnsafeLocalDockerBackend(image="ghcr.io/a2anet/adk-code-mode:latest"),
)Inside the sandbox, the model writes code like:
from tools.slack import send_message
print(send_message(channel="C123", text="hi"))Every turn runs in its own container, which the platform destroys when the turn ends — no cross-turn or cross-tenant state. The sandbox runs as a WebSocket server (set ADK_CODE_MODE_CONTROL_HTTP=1) and accepts exactly one connection, so you must configure your platform for one container per turn (--concurrency 1 on Cloud Run, or equivalent).
# Push the sandbox image to Artifact Registry
gcloud auth configure-docker <region>-docker.pkg.dev
docker pull --platform linux/amd64 ghcr.io/a2anet/adk-code-mode:latest
docker tag ghcr.io/a2anet/adk-code-mode:latest \
<region>-docker.pkg.dev/<project>/<repository>/adk-code-mode-sandbox:latest
docker push <region>-docker.pkg.dev/<project>/<repository>/adk-code-mode-sandbox:latest
# Create a VPC connector with no egress routes (blocks outbound network from sandbox)
gcloud compute networks create adk-sandbox-vpc --subnet-mode=custom
gcloud compute networks subnets create adk-sandbox-subnet \
--network=adk-sandbox-vpc \
--region=<region> \
--range=10.8.0.0/28
gcloud compute firewall-rules create adk-sandbox-deny-all-egress \
--network=adk-sandbox-vpc \
--direction=EGRESS \
--action=DENY \
--rules=all \
--priority=1000
gcloud compute networks vpc-access connectors create adk-sandbox-connector \
--region=<region> \
--subnet=adk-sandbox-subnet
# Deploy — note --concurrency 1, --vpc-egress=all-traffic, and the /health startup probe
gcloud run deploy adk-code-mode-sandbox \
--image <region>-docker.pkg.dev/<project>/<repository>/adk-code-mode-sandbox:latest \
--region <region> \
--port 8080 \
--cpu 1 \
--memory 1Gi \
--concurrency 1 \
--timeout 3600 \
--max-instances 120 \
--allow-unauthenticated \
--vpc-connector=adk-sandbox-connector \
--vpc-egress=all-traffic \
--set-env-vars "ADK_CODE_MODE_CONTROL_HTTP=1" \
--set-secrets "ADK_CODE_MODE_AUTH_TOKEN=<your-secret-name>:latest" \
--startup-probe "httpGet.path=/health,httpGet.port=8080,timeoutSeconds=3,periodSeconds=3,failureThreshold=80"These flags are recommendations to tune per deployment, not hard requirements.
--timeout 3600(Cloud Run's max) is the per-turn ceiling since the container holds the WebSocket for the whole turn;--max-instancesshould cover your peak concurrent turns (120covers a 10–100 target — verify your region's Cloud Run vCPU quota). The/healthstartup probe avoids cold-startHTTP 503s — Cloud Run's default TCP probe opens a raw socket the WebSocket server rejects.
Then in your agent:
RemoteBackend(
url="https://adk-code-mode-sandbox-xxxxx.run.app",
token="<your-secret>",
)
--concurrency 1is critical for security. It pins one turn to one container. Without this flag, Cloud Run may route multiple turns to the same container. The sandbox rejects the second connection, but the misconfiguration itself is a risk.
--vpc-egress=all-trafficwith a deny-all VPC is critical for security. Without it, user code can make arbitrary outbound requests — including hitting the GCP metadata endpoint (169.254.169.254) to steal the service account token, exfiltrating data, or scanning your VPC. The sandbox only needs to accept inbound connections; it never needs outbound access.
The same pattern works on any platform that runs Docker containers as HTTP services (AWS Fargate/ECS, Azure Container Instances, Kubernetes, Fly.io, etc.):
- One container per turn. Each container handles exactly one turn (one or more
execute_codecalls) and exits. - Block all outbound network access. Without egress restrictions, user code can exfiltrate data, access cloud metadata endpoints, or scan internal networks.
- Keep
/workspaceand/toolswritable. The sandbox stages the working directory and materialises thetoolspackage into/toolsat connect time. If you set a read-only root filesystem (e.g.,readOnlyRootFilesystem: truein Kubernetes), mount both as writable volumes (e.g., anemptyDir). - Authenticate connections. Set
ADK_CODE_MODE_AUTH_TOKENand layer platform-level auth (IAM, NetworkPolicy, security groups) on top.
Required env vars:
| Env var | Required | Default | Purpose |
|---|---|---|---|
ADK_CODE_MODE_CONTROL_HTTP |
yes | — | Set to 1 to run the sandbox as a WebSocket server (required for remote) |
ADK_CODE_MODE_AUTH_TOKEN |
yes | — | Bearer token for WebSocket auth |
PORT |
no | 8080 |
Listen port |
ADK_CODE_MODE_MAX_UPLOAD_TOOLS |
no | 100 MiB | Max tools tar archive size |
ADK_CODE_MODE_MAX_UPLOAD_WORKSPACE |
no | 100 MiB | Max workspace tar archive size |
Connection tuning, retry, and the same upload limits (plus a download limit) are configurable on RemoteBackend:
RemoteBackend(
url="...",
token="...",
connect_timeout=10.0, # seconds to wait for the WS handshake (default)
start_attempts=3, # connect attempts before giving up (default)
start_retry_delay_seconds=1.0, # linear backoff base: delay * attempt (default)
start_retry_jitter_seconds=0.25, # uniform jitter added per retry (default)
max_upload_tools_bytes=100 * 1024 * 1024, # 100 MiB (default)
max_upload_workspace_bytes=100 * 1024 * 1024, # 100 MiB (default)
max_download_workspace_bytes=100 * 1024 * 1024, # 100 MiB (default)
)Code Mode exposes two file surfaces:
-
The working directory — the turn's workspace. It persists across the turn's
execute_codecalls and resets between turns. Files created or changed by a call are collected afterward and saved as session artifacts automatically, returned to the model as a list of filenames (reloadable viaload_artifact) — nothing is re-hydrated into the working directory on the next turn unless the model explicitly loads it back withload_artifact. -
ADK Artifacts — persistent cross-turn storage.
ExecuteCodeToolinjects three tools into the sandbox:
import json
from tools import save_artifact, load_artifact, list_artifacts
save_artifact(
filename="report.json",
content=json.dumps({"status": "ready"}),
mime_type="application/json",
)
print(list_artifacts())
report = load_artifact(filename="report.json")
if report is not None and report["kind"] == "text":
payload = json.loads(report["data"])Pass include_artifact_tools=False to opt out. To react when the model saves an artifact, pass on_artifacts_saved:
async def on_saved(invocation_context, delta):
# delta is {filename: version} for everything the sandbox-side save_artifact
# calls (or wrapped tool results) saved this turn.
...
ExecuteCodeTool(tools=..., backend=..., on_artifacts_saved=on_saved)By default (save_tool_results_as_artifacts=True) every non-artifact tool is wrapped so its return value is saved as a session artifact tagged code_mode.tool_result = "true". This lets a host forward tool outputs to the user (read the marker in on_artifacts_saved) and keeps large results out of the model's context — a result whose serialised form exceeds the threshold is replaced in the reply with a short note pointing at the artifact, which the model reloads with load_artifact.
Naming is transparent: the filename is derived from the tool name and call id. Each wrapped tool also gains two optional parameters — artifact_name and artifact_description — that the model may pass to name/describe the saved artifact; both land in the artifact's custom_metadata (code_mode.artifact_name / code_mode.artifact_description) alongside the marker. Set save_tool_results_as_artifacts=False to return tool results inline without persisting them.
ExecuteCodeTool(tools=..., backend=..., save_tool_results_as_artifacts=True)The published base image (ghcr.io/a2anet/adk-code-mode) works as-is for tools whose execution is fully host-side. To bake in extra Python packages:
FROM ghcr.io/a2anet/adk-code-mode:latest
RUN pip install --no-cache-dir pandas==2.2.*The same image works for both RemoteBackend and UnsafeLocalDockerBackend. To build directly from this repo, run make docker-image.
Whatever you install is advertised to the model automatically — no host-side configuration. The sandbox reports its Python version and maps each top-level import name to the installed distribution and version that provides it; they appear in the <code-mode> block as <python-version> and <installed-packages>. Because the report only arrives once a container has booted, the first model call of a host process doesn't carry them yet; from then on the values are cached per sandbox image for the life of the process.
All settings are ExecuteCodeTool constructor arguments:
| Argument | Default | Purpose |
|---|---|---|
append_code_mode_metadata_to_system_instruction |
True |
Appends the <code-mode> block — Python version, installed packages, and the function catalog — to the system instruction on every model turn. Set False for a bare system instruction; the tool description then points the model at /tools/ instead, and the Python version and package list are not surfaced at all. |
max_code_mode_metadata_chars |
50_000 |
Budget for the whole rendered <code-mode> block, tags included. Oversized blocks degrade in tiers rather than disappearing — see What the model sees. |
max_output_chars |
50_000 |
Caps stdout/stderr handed back to the model. Overflow is saved as a session artifact at code_mode/stdout/<call-id>.txt and the model sees a head-and-tail view pointing to it. |
max_code_chars |
1_000_000 |
Rejects oversized code payloads before starting a container. |
timeout_seconds |
None |
Caps overall execution time of one execute_code call. Defaults to the platform request timeout (e.g. Cloud Run --timeout); set explicitly for defense in depth. |
per_tool_timeout_seconds |
None |
Caps each individual tool call made from within the sandbox. |
session_idle_timeout_seconds |
600 |
Idle reaper: closes a turn's container once it goes untouched this long. Backstop for turns that never call release_invocation. |
The model can read spilled stdout back from the overflow artifact:
from tools import load_artifact
spilled = load_artifact(filename="code_mode/stdout/<call-id>.txt")
print(spilled["data"][-2000:])A sandbox container is held open for one turn (one ADK invocation) and reused across that turn's execute_code calls, so cold start is paid at most once per turn. Python globals and the working directory persist across a turn's calls and reset between turns — use ADK Artifacts for cross-turn persistence. The container is released when the turn ends (via await tool.release_invocation(...), wired in Usage) and destroyed by the platform, so no state survives into another turn or tenant.
Host wheel (adk-code-mode). Lives in the same process as your LlmAgent. ExecuteCodeTool.process_llm_request resolves tools and, when append_code_mode_metadata_to_system_instruction is enabled, renders the <code-mode> block and appends it to the system instruction. At execution time (run_async), it generates a tools/ Python package of thin stubs, stages the working directory, and opens (or reuses) the turn's sandbox connection — the container spans the whole turn.
Sandbox wheel (adk-code-mode-sandbox). Pre-installed in the container image. When model code calls a stub, it sends a JSON-Lines frame over the control connection; the host runs the real tool (with callbacks and plugins) and sends the result back.
The only things crossing the boundary are: code, tool call arguments, tool return values, and log frames.
| Backend | Transport | Multi-tenant safe? | When to use |
|---|---|---|---|
RemoteBackend |
WebSocket over HTTPS | Yes | Production — any cloud platform |
UnsafeLocalDockerBackend |
TCP over Docker bridge | No | Local development only |
Two surfaces, deliberately kept apart. The tool description is the invariant contract of calling execute_code — the same for every deployment, and the model's only map when the <code-mode> block is disabled. execute_code has a single code: string parameter and this description:
Execute Python code in a sandboxed container. Custom tools are Python functions to
import from the `tools` package (e.g. `from tools.slack import send_message`). See
the `<code-mode>` section of the system instruction for the Python version,
preinstalled packages, and available functions. Print anything you want to see; only
stdout/stderr are returned. Variables and the working directory persist across calls
within the same turn and reset on the next turn — use `save_artifact` /
`load_artifact` (imported the same way) to persist across turns. Files created or
changed by the code are saved as artifacts automatically
With append_code_mode_metadata_to_system_instruction disabled that middle sentence is replaced by List /tools/ and read a file's docstring to see what's available., so the description never points at a section that wasn't appended.
The <code-mode> block is the per-deployment inventory of the sandbox, appended to the system instruction on every model turn:
…your instruction…
<code-mode>
<how-to-use>
This section describes the sandbox that `execute_code` runs in: the Python version available, the third-party packages preinstalled in it, and the functions you can import from the `tools` package.
</how-to-use>
<python-version>3.13.2</python-version>
<installed-packages>
pandas: pandas 2.3.3
yaml: PyYAML 6.0.2
</installed-packages>
<tools-package>
# tools.slack
from tools.slack import list_channels, send_message
def list_channels() -> Any:
"""List Slack channels."""
...
def send_message(*, channel: str, text: str, thread_ts: str | None = ...) -> Any:
"""Send a message to a Slack channel."""
...
# tools
from tools import save_artifact, load_artifact, list_artifacts
…
</tools-package>
</code-mode>
Tags are indented; their content is not. The <tools-package> body is Python source, where leading whitespace is meaningful. Each installed-package line is <import name>: <distribution> <version>; namespace imports supplied by multiple distributions list every provider in deterministic order. Import names and providers are sorted so the block stays byte-stable for prompt caching. <python-version> and <installed-packages> are omitted entirely — rather than rendered blank — when the sandbox hasn't reported yet or the image has no extra packages, since an empty tag reads as "none exist".
With save_tool_results_as_artifacts enabled (the default), each non-artifact tool above — e.g. list_channels and send_message — also carries two optional artifact_name: str | None = ... / artifact_description: str | None = ... parameters for naming its saved result.
Stubs are rendered from each tool's declaration, so whatever the declaration drops can never reach the model. Nested object properties, their descriptions and JSON Schema's validation keywords all land in the docstring, because no Python type expresses them:
def create_minimum_spend_rule(*, space_id: int, conditions: dict[str, Any] | None = ..., label: str | None = ...) -> Any:
"""Creates a new minimum spend rule under the MinimumSpend policy.
Args:
space_id: The unique identifier of the venue space. (format=int32)
conditions: Conditions that must all be satisfied for the rule to apply (AND logic).
daysOfWeek: list[int] | None - Days of the week (1=Monday, 7=Sunday). (items: minimum=1, maximum=7)
timeRange: dict[str, Any] | None - Time window of the booking start time.
from: str (pattern=^([01][0-9]|2[0-3]):[0-5][0-9]$, example=18:00)
to: str (example=23:59)
label: (maxLength=255)
"""Every JSON Schema 2020-12 Validation and Meta-Data keyword is covered; tests/test_stubs_json_schema.py reads the keyword set from the meta-schemas and fails if one goes unclassified.
Important
Enable ADK's JSON_SCHEMA_FOR_FUNC_DECL feature to get the full picture. It is off by default, and without it ADK builds declarations through a Gemini types.Schema, which reduces an allOf-wrapped $ref — how OpenAPI 3.0 spells a nullable reference — to a bare {"type": "OBJECT"}. The referenced properties and description are gone before this package sees them, and the field renders as an empty dict[str, Any].
from google.adk.features import FeatureName, override_feature_enabled
override_feature_enabled(FeatureName.JSON_SCHEMA_FOR_FUNC_DECL, True)Or set ADK_ENABLE_JSON_SCHEMA_FOR_FUNC_DECL=1 in the environment. Do this before building any tool. It changes how declarations are built for every tool in the process, so if the same process also exposes tools to the model directly, check the model layer accepts parameters_json_schema — ADK's LiteLLM integration does, and ExecuteCodeTool declares itself that way regardless.
A block over max_code_mode_metadata_chars degrades in tiers instead of vanishing. The Python version and package list survive every tier — they're small, and the model cannot discover them any other way.
-
Full — as above.
-
Names only — signatures and docstrings dropped, import lines kept:
<tools-package> # tools.slack from tools.slack import list_channels, send_message </tools-package> -
Pointer — the catalog is replaced by a discovery note, and this tier is emitted no matter how small the budget:
<tools-package> The full catalog is too large to include here. List `/tools/` and read a file's docstring to see what's available. </tools-package>
At the last tier the model navigates the sandbox from Python instead:
import pathlib
print(list(pathlib.Path("/tools").iterdir()))
print(open("/tools/slack/send_message.py").read()) # signature + docstringText and JSON-like MIME types travel as plain strings in artifact tools; binary content is base64-encoded. load_artifact returns {"kind": "text" | "bytes", "data": str, "mime_type": str | None}.
RemoteBackend is designed for multi-tenant production use where untrusted users submit arbitrary Python code:
- One container per turn (one tenant, one invocation). Within a turn the process/filesystem are reused across that turn's
execute_codecalls; the container is destroyed at turn end, with no cross-turn or cross-tenant sharing. - Environment sanitization. All env vars are stripped except a safe allowlist (
PATH,HOME,USER, locale vars, Python config) before user code runs. - Credentials never enter the sandbox. API keys, OAuth tokens, and connection strings stay in the host process. The container only receives tool results.
- Bearer token authentication. WebSocket connections without a valid token are rejected. Always set
ADK_CODE_MODE_AUTH_TOKENand layer platform-level auth on top. - Hardened tar extraction. Path traversal (
../), symlinks, hardlinks, and absolute paths are rejected. - Non-root user. The sandbox runs as
sandbox, not root. - Tool dispatch runs ADK's guard callbacks.
before_tool,after_tool,on_error, and the plugin manager all fire normally. - Bounded inputs and outputs. See Configuration for
max_code_chars,max_output_chars,timeout_seconds,per_tool_timeout_seconds, and upload/download size limits.
Do not use in production or for multi-tenant workloads.
Named "Unsafe" intentionally: it binds a TCP listener on 0.0.0.0, communicates over unencrypted TCP, and relies on the local Docker daemon. It does still sanitize env vars, run as non-root, drop all Linux capabilities (cap_drop=["ALL"]), and mount the root filesystem read-only — but it is not a security boundary for untrusted users.
- Network egress (if you skip egress restrictions). The sandbox does NOT block outbound network by itself — configure this at the platform level. Without it, user code can exfiltrate data, access cloud metadata endpoints (
169.254.169.254), or scan internal networks. See Remote Deployment. - Container runtime escapes. Keep your container runtime patched.
- Exfiltration through legitimate tool calls. If your tool surface includes
send_email, a prompt-injected payload could use it. Keep your tool surface least-privilege. - Denial of service within resource limits. User code can consume its full CPU/memory allocation. Set platform-level limits.
- No credential-requesting tools. Tools that need ADK to request credentials, confirmations, UI widgets, agent transfer, escalation, or that yield without an immediate response are rejected with a structured error.
- State is turn-scoped. Variables and the working directory persist across
execute_codecalls within a turn, but reset between turns. Usesave_artifact/load_artifactto persist across turns. - No runtime package installation. The sandbox ships with the Python Standard Library and the runtime's own dependencies only. Extra packages must be baked into the image at build time.
make install # uv sync --group dev
make ci # ruff + mypy + pytestDocker integration tests are opt-in:
uv run pytest -m dockeradk-code-mode is distributed under the terms of the Apache-2.0 license.
A2A Net is a site to find and share AI agents and open-source community. Join to share your A2A agents, ask questions, stay up-to-date with the latest A2A news, be the first to hear about open-source releases, tutorials, and more!
- 🌍 Site: A2A Net
- 🤖 Discord: Join the Discord