Skip to main content

A "Code Mode" Code Executor for ADK for agents to interact with tools, files, and custom packages with Python

Project description

ADK Code Mode

A Code Mode code executor for Agent Development Kit (ADK).

The CodeModeCodeExecutor allows ADK agents to 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. It also supports input_files and output_files, and the sandboxed container can list, load, and save ADK Artifacts.

Inspired by Cloudflare's Code Mode and Anthropic's Code execution with MCP.

✨ Features

  • Call ADK tools from sandbox code — imports against the tools package proxy back to the host and run through ADK's before_tool / after_tool / on_error callbacks 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 runtime pip install required.
  • Cross-turn persistence via ADK Artifactssave_artifact / load_artifact / list_artifacts are auto-injected and route through your configured ArtifactService.
  • Bounded stdout/stderr — overflow lands in a session artifact instead of poisoning the prompt.
  • Production-ready remote sandboxRemoteBackend connects to an isolated per-turn container over WebSocket, reused across the turn's code blocks. Deploy on any cloud platform (Cloud Run, Fargate, ACI, Kubernetes, Fly.io, etc.).
  • Local developmentUnsafeLocalDockerBackend runs 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 yes
Output files no yes yes no no yes
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)

📦 Install

pip install adk-code-mode

Or with uv:

uv add adk-code-mode

For 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.

🚀 Usage

Build a CodeModeCodeExecutor, then wire three things into the agent:

  • CODE_MODE_SYSTEM_INSTRUCTION — append to the agent's instruction. Teaches the model how to write code blocks and use artifacts.
  • code_mode_before_model_callback — set as before_model_callback. Injects the tool catalog (<code-mode> block) into the system prompt on every model turn.
  • generate_content_config with function_calling_config.mode="NONE" — disables native function calling so the model writes Python instead of attempting tool calls that fail with MALFORMED_FUNCTION_CALL (since tools=[]).

Production (remote sandbox)

from google.adk.agents import LlmAgent
from google.genai import types as genai_types
from adk_code_mode import (
    CODE_MODE_SYSTEM_INSTRUCTION,
    CodeModeCodeExecutor,
    RemoteBackend,
    code_mode_before_model_callback,
)

executor = CodeModeCodeExecutor(
    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
    ),
)

root_agent = LlmAgent(
    name="assistant",
    model="gemini-2.5-pro",
    instruction=f"You are a helpful assistant.\n\n{CODE_MODE_SYSTEM_INSTRUCTION}",
    tools=[],  # do NOT also bind tools here; the executor owns them.
    code_executor=executor,
    generate_content_config=genai_types.GenerateContentConfig(
        tool_config=genai_types.ToolConfig(
            function_calling_config=genai_types.FunctionCallingConfig(mode="NONE"),
        ),
    ),
    before_model_callback=code_mode_before_model_callback(executor),
)

Local development only

UnsafeLocalDockerBackend is not safe for production or multi-tenant use. See Safety.

from adk_code_mode import (
    CODE_MODE_SYSTEM_INSTRUCTION,
    CodeModeCodeExecutor,
    UnsafeLocalDockerBackend,
    code_mode_before_model_callback,
)

executor = CodeModeCodeExecutor(
    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"))

🌐 Remote Deployment

Every turn runs in its own container. The container accepts exactly one WebSocket connection and holds it open for the whole turn, executing one or more code blocks with persistent globals + /workspace, then exits when the turn ends (ShutdownFrame or disconnect). The hosting platform destroys the container after each turn — no cross-turn or cross-tenant data leakage, no residual state. You must configure your platform for one container per turn (--concurrency 1 on Cloud Run, or equivalent).

Setting ADK_CODE_MODE_CONTROL_HTTP=1 activates HTTP mode. The container:

  1. Starts a WebSocket server on port 8080 (configurable via PORT)
  2. Accepts exactly one connection (rejects further connections with 503)
  3. Receives the tools tar once, then announces readiness
  4. Sanitizes the environment (strips all env vars except a safe allowlist)
  5. For each code block: receives that block's workspace tar, executes user code (tools proxied back to the host over the same WebSocket; globals + /workspace persist across blocks), then returns stdout/stderr and the updated workspace
  6. Exits when the turn ends (ShutdownFrame or disconnect)

Deploy to Cloud Run

# 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 and --vpc-egress=all-traffic
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"

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-instances should cover your peak concurrent turns (120 covers a 10–100 target — verify your region's Cloud Run vCPU quota). Also add the /health startup probe below.

Then in your agent:

RemoteBackend(
    url="https://adk-code-mode-sandbox-xxxxx.run.app",
    token="<your-secret>",
)

--concurrency 1 is 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-traffic with 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.

Configure an HTTP startup probe against /health. Cloud Run's default TCP probe opens a raw socket that the websockets server rejects as a malformed HTTP request, which can race with real traffic during cold starts and surface to clients as HTTP 503 on the WebSocket handshake. Add --startup-probe="httpGet.path=/health,httpGet.port=8080,timeoutSeconds=3,periodSeconds=3,failureThreshold=80" (or equivalent) so probes hit the /health endpoint the sandbox handles natively. Keep periodSeconds small (e.g. 3): it sets how long Cloud Run waits before re-probing, so a short period lets a ready container start serving in ~3s instead of ~10s — raise failureThreshold to keep the same overall startup budget (periodSeconds × failureThreshold). Turn-scoping already gives demand-driven warmth (a container stays up for the whole turn), so --min-instances=0 is fine; only set --min-instances if you want to eliminate cold starts at the cost of a paid warm pool.

Deploy on other platforms

The same pattern works on any platform that runs Docker containers as HTTP services (AWS Fargate/ECS, Azure Container Instances, Kubernetes, Fly.io, etc.):

  1. One container per turn. Each container handles exactly one turn (one or more code blocks) and exits.
  2. Block all outbound network access. Without egress restrictions, user code can exfiltrate data, access cloud metadata endpoints, or scan internal networks.
  3. Set a read-only root filesystem where the platform supports it (e.g., readOnlyRootFilesystem: true in Kubernetes). The sandbox only writes to /workspace.
  4. Authenticate connections. Set ADK_CODE_MODE_AUTH_TOKEN and 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 activate HTTP mode
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)
)

connect_timeout defaults to 10s — fail a stalled connect fast and retry rather than block. RemoteBackend retries transient connect failures up to start_attempts times with linear backoff + jitter; raise connect_timeout (and/or start_attempts) for platforms with slow cold starts.

🗂️ Storage

Code Mode exposes two file surfaces:

  • /workspace — the turn's working directory. It persists across the turn's code blocks and resets between turns. ADK input_files for a block are staged here before that block runs (open("input.csv") works). Files whose content changed in a block are returned as that block's CodeExecutionResult.output_files; nothing under /workspace is re-hydrated on the next turn unless persisted via save_artifact.

  • ADK Artifacts — persistent cross-turn storage. CodeModeCodeExecutor injects three tools into the catalog:

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 saved this turn.
    ...

CodeModeCodeExecutor(tools=..., backend=..., on_artifacts_saved=on_saved)

🐳 Sandbox Image

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.

⚙️ Configuration

Catalog overflow

max_catalog_chars (default 50_000) is a soft cap on the rendered tool catalog in the system prompt. When exceeded, the per-tool sections are replaced with a short note telling the model how to navigate /tools/ from Python.

CodeModeCodeExecutor(tools=..., backend=..., max_catalog_chars=20_000)

Output truncation

max_output_chars (default 50_000) caps stdout and stderr handed back to the model. Overflow is saved as a session artifact at code_mode/stdout/<execution-id>.txt, and the model sees a head-and-tail view with a marker pointing to it.

from tools import load_artifact
spilled = load_artifact(filename="code_mode/stdout/<execution-id>.txt")
print(spilled["data"][-2000:])

Code size limit

max_code_chars (default 1_000_000) rejects oversized code payloads before starting a container.

Timeouts

timeout_seconds caps overall execution time; per_tool_timeout_seconds caps each individual tool call. Both default to None (relying on platform timeouts). Set them explicitly for defense in depth:

CodeModeCodeExecutor(
    tools=...,
    backend=...,
    timeout_seconds=30,
    per_tool_timeout_seconds=10,
)

timeout_seconds / per_tool_timeout_seconds stay None by default; with turn-scoping the effective per-turn wall-clock ceiling is the platform request timeout (e.g. Cloud Run --timeout).

Turn-scoped sessions

A sandbox container is held open for the duration of a turn (one ADK invocation) and reused across that turn's code blocks, so cold start is paid at most once per turn instead of once per block. State — Python globals and /workspace — persists across a turn's blocks and resets between turns (use ADK Artifacts for cross-turn persistence).

The container is released when the turn ends. Wire code_executor.release_invocation(callback_context.invocation_id) into your agent's after_agent_callback to free it the moment the turn finishes:

def _release(callback_context):
    code_executor.release_invocation(callback_context.invocation_id)

agent = LlmAgent(..., after_agent_callback=[_release])

Two safety nets cover turns that never signal a clean end: an idle reaper closes sessions idle longer than session_idle_timeout_seconds (default 600), and a mid-turn connection loss (or a timeout_seconds firing) drops the session — in-turn state (globals + /workspace) is lost and the next block reconnects on a fresh container.

If the connection drops while a block is running, the executor re-runs the block only when the code provably never reached the sandbox, so a reconnect can't duplicate a tool call's side effects. What happens depends on how far the block got before the drop:

  • Not run — the RunFrame never left the host, so the code did not execute; the executor reconnects on a fresh container and retries the block once automatically.
  • Ran (output lost) — a DoneFrame came back before the drop, so the code executed but its stdout/stderr and output files couldn't be returned; the block is not re-run.
  • Unknown — the RunFrame was sent but no DoneFrame arrived, so whether the code executed is unknowable; the block is not re-run.

For the Ran and Unknown cases the block returns a short message in its stderr telling the model what happened, so it can decide whether to re-run the code or check for its effects rather than the executor silently repeating it.

CodeModeCodeExecutor(tools=..., backend=..., session_idle_timeout_seconds=600)

Turn-scoping is wire-protocol v2. The host wheel and the sandbox image must be upgraded together — a version mismatch fails fast with ProtocolVersionMismatchError — so rebuild and push the sandbox image when you upgrade.

🏗️ Architecture

Host wheel (adk-code-mode). Lives in the same process as your LlmAgent. The before_model_callback resolves tools, renders the catalog, and appends it to the system prompt. At execution time, it generates a tools/ Python package of thin stubs, stages the block's input_files into /workspace, 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

What the model sees

Your instruction (containing CODE_MODE_SYSTEM_INSTRUCTION) followed by a <code-mode> block appended by the callback:

…your instruction…

# How to execute code and use tools
Code you write in a fenced Python block (i.e. ```python) will be executed in a sandbox.
The Python Standard Library and a custom set of tools are available to you.
To see the result of your code, you need to print it.

For example, if you had the following tool:

from tools.slack import send_message

def send_message(*, channel: str, text: str, thread_ts: str | None = ...) -> Any: """Send a message to a Slack channel.""" ...


To call the tool, you should write:

"""
```python
from tools.slack import send_message

print(send_message(channel="C123", text="hi"))

"""

How to use files and variables in between executions

Within a turn the sandbox is stateful: variables you define and files you write under /workspace (the working directory) persist across the successive code blocks you run before replying to the user. They reset at the start of your next turn. To carry data across turns, use Artifacts: list them with the list_artifacts tool, save with save_artifact, and load with load_artifact.

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 …

```

When the rendered catalog exceeds max_catalog_chars, the per-tool sections are replaced with:

<code-mode>
A `tools` package is available in the sandbox. List `/tools/` with
`pathlib.Path('/tools').iterdir()`. Each entry is either a `.py` file
(a top-level tool, importable as `from tools import <name>`) or a
subdirectory (a namespace, with tools importable as
`from tools.<namespace> import <name>`). To see a tool's signature and
docstring, read its `.py` file with `open(...).read()`.
</code-mode>

Text 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}.

🛡️ Safety

RemoteBackend (production)

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 code blocks; 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_TOKEN and 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.

UnsafeLocalDockerBackend (development only)

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.

What this does NOT protect against

  • 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.

⚠️ Limitations

  • 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 /workspace files persist across code blocks within a turn, but reset between turns. Use save_artifact / load_artifact to 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.

🛠️ Development

make install       # uv sync --group dev
make ci            # ruff + mypy + pytest

Docker integration tests are opt-in:

uv run pytest -m docker

📄 License

adk-code-mode is distributed under the terms of the Apache-2.0 license.

🤝 Join the A2A Net Community

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!

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

adk_code_mode-0.3.0.tar.gz (305.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

adk_code_mode-0.3.0-py3-none-any.whl (59.7 kB view details)

Uploaded Python 3

File details

Details for the file adk_code_mode-0.3.0.tar.gz.

File metadata

  • Download URL: adk_code_mode-0.3.0.tar.gz
  • Upload date:
  • Size: 305.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for adk_code_mode-0.3.0.tar.gz
Algorithm Hash digest
SHA256 db4204c5a280407bb137e6b9bc4c009e6f8bc5eab005544153739c97544c432d
MD5 e32d1be713b7fedd9f2587f99153fccf
BLAKE2b-256 77cab32ce5b8f78de16a4c0ba5d091bc522b8fc2320e8aae579e3e5b6eda1be4

See more details on using hashes here.

Provenance

The following attestation bundles were made for adk_code_mode-0.3.0.tar.gz:

Publisher: release-please.yml on a2anet/adk-code-mode

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file adk_code_mode-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: adk_code_mode-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 59.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for adk_code_mode-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f0491cbe1c928ae30f3e5031db3e2a1d8e13236e45af6c0e240217b1eea914e8
MD5 aa77792148b548e4d87067f0d3549bfa
BLAKE2b-256 eb49b960307ce8afb02597be6144787d9fdd3b688f659f846db6362e6452d559

See more details on using hashes here.

Provenance

The following attestation bundles were made for adk_code_mode-0.3.0-py3-none-any.whl:

Publisher: release-please.yml on a2anet/adk-code-mode

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page