all posts

How to give a Strands agent a code execution tool

Ajay Kumar··16 min read

Strands Agents is AWS's open-source agent SDK, and its pitch is that the model drives the loop. You hand an Agent a model and a list of tools, the model decides what to call, and the SDK runs the loop until the model stops asking for things. A tool is a Python function with a decorator on it: the signature supplies the parameter schema, the type hints supply the types, and the docstring supplies the description the model reads. There is no separate schema file and no builder API to learn.

Which means a code-execution tool is about twelve lines long, and about nine of those lines are a remote code execution endpoint. I build PandaStack, a Firecracker microVM platform, so I see this tool a lot — usually in the form of a screenshot from someone asking why their agent just deleted a directory. This post is the version that survives contact with a stranger: where the code actually runs, how the sandbox lives across turns, what streams back while it runs, and what shape the result takes when it re-enters the model's context.

Verified against strands-agents 1.54.0 on PyPI (published 2026-08-27, Python 3.10+), reading the shipped package source rather than a blog post. Strands moves fast and has a large surface, so check the current docs at strandsagents.com before pasting anything: the sandbox interface described here is relatively new, and the decorator options have grown over time. Where I could not confirm a detail from the source, I say so instead of guessing.

The tool everyone writes first

Here it is. It works on the first try, it demos beautifully, and I have watched it ship.

# pip install strands-agents
import subprocess

from strands import Agent, tool


@tool
def run_python(code: str) -> str:
    """Run a short Python snippet and return whatever it printed.

    Args:
        code: The Python source to run.
    """
    proc = subprocess.run(
        ["python3", "-c", code],
        capture_output=True,
        text=True,
        timeout=30,
    )
    return proc.stdout + proc.stderr


agent = Agent(tools=[run_python])
agent("How many primes are there below one million?")

The docstring says "run a short Python snippet". Read what the function actually grants, because the model reads the grant, not the docstring:

  • os.environ - every secret your deployment injects. The Bedrock credentials the agent is using right now, your database URL, whatever else is in the process.
  • The instance metadata endpoint at 169.254.169.254 - on EC2 or ECS, the role your task is running as, in a form that can be exported and used from anywhere.
  • The filesystem, as whatever user your service runs as, including the key material you mounted for something unrelated.
  • Your network position. Private subnets, internal services that trust anything originating from this box, the Redis with no password because it is "internal".
  • os.fork(). The timeout kills the child it can see. It does not kill the grandchild that detached and kept running.

Reaching for exec() instead of a subprocess makes it worse, not better, which surprises people who do it to avoid the process overhead. exec() has no separate process at all: generated code runs inside your interpreter, sharing your module globals, your open connections, your signal handlers. You cannot time it out cleanly and you cannot reclaim memory it decided to allocate. Same permission grant, isolation removed.

Nobody has to attack you for this to go wrong. The model only has to be confidently wrong about which directory it is in, which is a thing models are wrong about weekly.

And the adversarial case is not exotic either. The snippet the model writes is downstream of everything in its context. If any of that context came from a scraped page, an uploaded CSV, a Jira ticket, or a support email, then a stranger has partial write access to the string you are about to hand the interpreter. Prompt injection stops being a research curiosity at that point and becomes a code path with a file name and a line number.

The reflex fix is to filter the code - reject anything containing import os, block subprocess, scan for eval. This has never worked. Python has too many routes to the same object graph, and you are writing a parser that must be right every time against a generator that only has to be right once. Filtering catches honest mistakes. It is not a boundary.

Strands already named the problem for you

Here is the thing I did not expect when I went reading the package source. Strands ships a first-class sandbox abstraction: the Agent constructor takes a sandbox argument, there is an abstract Sandbox base class with streaming execution and file operations, and there are built-in tools that route through whatever sandbox the agent holds. When you do not pass one, the agent falls back to a default implementation. The class name of that default is NotASandboxLocalEnvironment.

The maintainers put the warning in the type name. I have a lot of time for that.

from strands import Agent
from strands.vended_tools import shell

agent = Agent(tools=[shell])

print(type(agent.sandbox).__name__)
# NotASandboxLocalEnvironment
#
# Every command the model writes runs on this host, as this process's
# user, with this process's environment. The class name is the docs.

The shipped alternatives are a Docker sandbox (docker exec into a container you name) and an SSH sandbox (commands over OpenSSH to a host you name). Both are real improvements over the host default and both are useful. Docker in particular is the right answer for a lot of teams, and I am not going to pretend otherwise: it is one line of config, everybody already has the daemon, and it stops the casual accidents. What it does not give you is a kernel boundary — the container shares your host kernel, so a kernel bug is a host compromise, and if the daemon socket is reachable from the agent process then "escaped the container" is a two-line script. That tradeoff is fine for your own code. It is a different conversation when the code is written by a model reading a stranger's CSV.

What matters for the rest of this post is that the extension point is public and small. If you can run a shell command somewhere and stream its output back, you can be a Strands sandbox, and every sandbox-aware tool in the SDK will route through you. That is the cleanest sandbox interface I have seen in a Python agent framework, and it is worth building against rather than around.

What "isolated" has to actually mean

Before the code, the requirements. "Sandboxed" gets used to mean anything from a chroot to a try/except, so here is the list I hold a code-execution environment to. Not all of it matters for every workload, but you should know which lines you are choosing to skip.

  • Separate kernel. Not a namespace on your kernel - a different one. This is the line between "a container escape is a bug" and "a container escape is your whole host".
  • No inherited credentials. The environment holds nothing your agent process holds. Not the model provider key, not the IAM role, not the database URL. If the sandbox needs a secret, you put exactly that one secret in it deliberately.
  • No network position. A machine on its own network namespace, whose default answer to "can I reach the internal admin service" is no, and whose access to a package index is a decision you made rather than one you inherited.
  • Its own filesystem, disposable. Writes go to a copy nobody else can see, and destroying it is cheap enough that you do it routinely rather than as an incident response.
  • Killable from outside. When execution runs away, you do not negotiate with the process. You destroy the machine.
  • Cheap enough that you do not cheat. This is the one people skip, and it is why isolation gets abandoned. If a fresh environment takes thirty seconds and costs real money, somebody will share one across users by Thursday.

A Firecracker microVM hits all six, which is why it is what PandaStack runs. Each sandbox is a real VM with its own kernel, its own network namespace, and a copy-on-write root filesystem. Creates are a snapshot restore rather than a boot — p50 around 179ms — so the last requirement holds too: a fresh machine per conversation is cheaper than the model's first token, which removes the excuse for sharing one.

I will say the honest version of that up front rather than at the end: none of this matters if the only person who can influence your agent's context is you. Skip to the last section if that is your situation.

The tool, running somewhere else

The simplest correct version keeps the exact shape of the naive tool and changes where the interpreter lives. The function stops executing code and becomes a courier: it writes the source into a sandbox, runs it there, and returns text.

# pip install strands-agents pandastack
from strands import Agent, ToolContext, tool
from pandastack import Sandbox as MicroVM

# Note the alias. strands exports a Sandbox type of its own, and importing
# both unaliased is a debugging session you do not need.


@tool(context="tool_context")
def run_python(code: str, tool_context: ToolContext) -> str:
    """Execute Python in an isolated Linux microVM and return its output.

    Use this for ANY calculation, data transformation, file parsing or
    inspection the request implies. Compute the answer here; do not
    estimate it in your head and do not describe code you did not run.

    The sandbox is a separate machine. It cannot see this application, its
    environment or its network. Files you write under /workspace persist
    for the whole conversation, so load a dataset once and reuse the file.
    Variables do NOT survive between calls - each call is a fresh
    interpreter - so print anything you need to keep.

    pandas, numpy and matplotlib are installed. Write generated files to
    /workspace/out and tell the user the filename; never print file bytes
    or base64 into your answer.

    If the code raises, you get the traceback back as text. Read it, fix
    the code, and call this tool again.

    Args:
        code: Python source to execute. Include every import you use.
        tool_context: Injected by the framework. Not model-facing.

    Returns:
        A plain-text block with status, exit code, stdout and stderr.
    """
    session_id = tool_context.invocation_state.get("session_id")
    if not session_id:
        return "status: error\nno session bound to this invocation"

    vm = vm_for(session_id)
    n = next_cell(session_id)
    path = f"/workspace/cells/{n:03d}.py"

    # Write the source to a file rather than shelling in a -c string: no
    # quoting bugs, and tracebacks carry line numbers that match the code
    # the model wrote, which is the difference between an agent that
    # self-corrects and one that guesses.
    vm.filesystem.write(path, code)
    result = vm.exec(f"cd /workspace && python3 {path}", timeout_seconds=60)
    return shape(result)


agent = Agent(tools=[run_python], system_prompt=ANALYST_PROMPT)
agent("Which SKUs in /workspace/orders.csv lost money last quarter?",
      session_id=thread_id)

Three Strands-specific things in there are worth pulling out.

The decorator takes context="tool_context", which tells the SDK to inject a ToolContext into the parameter of that name. The context carries the ToolUse, the agent itself, a cancellation signal, and — the useful part here — invocation_state, which is the dictionary of extra keyword arguments you passed when you invoked the agent. That is how session_id reaches the tool without becoming a model-supplied argument. The parameter is excluded from the schema the model sees, so the model cannot set it.

It has to work that way. If session_id were an ordinary tool parameter, the model would be able to name any session it liked, and a model that can name any session can attach to somebody else's sandbox. It will not usually do that on purpose. It will do it because it saw a session id in a log line three turns ago and pattern-matched. Derive the id from context you control, every time.

And the docstring is the schema. Strands builds the tool description from it, so it is not documentation for a future colleague — it is the instruction the model reads at the moment it decides whether to call your tool and what to put in the arguments.

Write the docstring like a prompt, because it is one

The most common complaint about code tools is "the model won't use it", and the second most common is "the model uses it wrong". Both are almost always a docstring that describes what the tool is instead of when to call it and how the environment behaves. Every fact you leave out gets replaced by a confident model assumption, and the assumptions are predictable enough that I can list the failures.

  • State - Unstated: the model assumes a REPL, references a DataFrame it built three calls ago, and gets a NameError it cannot explain. Stated: it either rebuilds what it needs or uses the persistent kernel deliberately.
  • Packages - Unstated: it imports something that is not installed, gets an ImportError, and tries pip install in a sandbox with no network. Stated: it works with what is there, or asks the user.
  • Filesystem - Unstated: it writes output to the current directory, which it guesses, and the next call cannot find the file. Stated: everything lands in /workspace and the paths line up.
  • Errors - Unstated: it treats a returned traceback as a final answer and apologises to your user. Stated: it reads stderr, fixes the bug, calls again - which models are genuinely good at.
  • Output limits - Unstated: it prints an entire DataFrame and then reasons over a silently truncated tail. Stated: it prints shape and head because you told it output is capped.
  • Network - Unstated: it spends three turns retrying an HTTP call against a wall. Stated: it plans around the wall.

Six extra sentences buy all of that. It is the highest-leverage prose in the codebase and it is the least reviewed, because it looks like a comment. If you only change one thing after reading this post, change the docstring.

One mechanical note: the decorator also accepts name and description overrides, so you can keep an implementation docstring for humans and hand the model a separately maintained description string. Tool names have to match a conservative character class and stay within 64 characters, which is worth knowing before you get creative with them.

The better version: be a Strands sandbox

The tool above works and you can stop there. But Strands has that sandbox interface, and implementing it buys you something the bespoke tool does not: every sandbox-aware tool in the SDK — the built-in shell, the built-in file editor, anything you or a colleague writes against agent.sandbox — routes into your microVM without knowing anything about it. You write one adapter and the whole tool ecosystem inherits the isolation.

The interface is deliberately small. Subclass PosixShellSandbox and the only method you must implement is execute_streaming: an async generator that yields StreamChunk objects as output arrives and one final ExecutionResult. Code execution, file reads, file writes, directory listing and removal are all implemented in the base class on top of that one primitive, by piping base64 through a quoted heredoc. You are free to override any of them with something native, and for file I/O you should.

import asyncio
import shlex
from collections.abc import AsyncGenerator
from typing import Any

from strands import SandboxTimeoutError
from strands.sandbox import ExecutionResult, PosixShellSandbox, StreamChunk
from strands.sandbox.posix_shell import build_shell_env_prefix
from strands.types.tools import AgentTool
from strands.vended_tools.file_editor import make_file_editor
from strands.vended_tools.shell import make_shell

from pandastack import Sandbox as MicroVM, SandboxTimeout

_DONE = object()


class MicroVMSandbox(PosixShellSandbox):
    """A Strands sandbox backed by one Firecracker microVM."""

    def __init__(self, vm: MicroVM, *, working_dir: str = "/workspace") -> None:
        self._vm = vm
        self.working_dir = working_dir

    @classmethod
    def create(cls, *, template: str = "code-interpreter",
               ttl_seconds: int = 3600,
               metadata: dict[str, str] | None = None) -> "MicroVMSandbox":
        return cls(MicroVM.create(
            template=template,
            ttl_seconds=ttl_seconds,           # the backstop, always
            metadata=metadata or {},
        ))

    async def execute_streaming(
        self,
        command: str,
        *,
        timeout: float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        **kwargs: Any,
    ) -> AsyncGenerator[StreamChunk | ExecutionResult, None]:
        # The base class does NOT apply cwd, env or timeout for you. A
        # subclass that ignores them silently drops the caller's options,
        # which is a fun bug to find six weeks later.
        prefix = build_shell_env_prefix(env)   # validates keys, quotes values
        workdir = shlex.quote(cwd or self.working_dir)
        full = f"cd {workdir} && {prefix}{command}"

        loop = asyncio.get_running_loop()
        q: asyncio.Queue = asyncio.Queue()

        def pump(kind: str):
            def _(text: str) -> None:
                loop.call_soon_threadsafe(q.put_nowait, (kind, text))
            return _

        def run() -> None:
            try:
                code = self._vm.exec_stream(
                    full,
                    on_stdout=pump("stdout"),
                    on_stderr=pump("stderr"),
                    timeout_seconds=int(timeout) if timeout else None,
                )
            except SandboxTimeout:
                loop.call_soon_threadsafe(q.put_nowait, ("timeout", ""))
                code = 124
            except Exception as e:                       # transport, not user code
                loop.call_soon_threadsafe(
                    q.put_nowait, ("stderr", f"sandbox transport error: {e}\n"))
                code = 1
            loop.call_soon_threadsafe(q.put_nowait, (_DONE, code))

        worker = asyncio.create_task(asyncio.to_thread(run))
        out: list[str] = []
        err: list[str] = []
        timed_out = False
        exit_code = 1

        while True:
            kind, payload = await q.get()
            if kind is _DONE:
                exit_code = payload
                break
            if kind == "timeout":
                timed_out = True
                continue
            (out if kind == "stdout" else err).append(payload)
            yield StreamChunk(data=payload, stream_type=kind)

        await worker
        if timed_out:
            raise SandboxTimeoutError(timeout)

        yield ExecutionResult(
            exit_code=exit_code,
            stdout="".join(out),
            stderr="".join(err),
        )

    # Native file I/O. The inherited versions shell out and base64 both
    # directions, which is correct but pays a round trip and a 33% size
    # tax on every byte. An SDK call is strictly better when you have one.
    async def read_file(self, path: str, **kwargs: Any) -> bytes:
        return await asyncio.to_thread(self._vm.filesystem.read, path)

    async def write_file(self, path: str, content: bytes, **kwargs: Any) -> None:
        await asyncio.to_thread(self._vm.filesystem.write, path, content)

    def get_tools(self) -> list[AgentTool]:
        """Tools this sandbox vends. The agent registers these itself."""
        return [
            make_shell(
                sandbox=self,
                name="shell",
                description=(
                    "Executes a shell command inside an isolated Linux microVM. "
                    "Each call runs in a fresh shell, so variables and the "
                    "working directory do not persist between calls; files "
                    "under /workspace do. There is no access to the host."
                ),
            ),
            make_file_editor(sandbox=self, name="file_editor"),
        ]

    async def aclose(self) -> None:
        await asyncio.to_thread(self._vm.kill)

Then the wiring is one argument:

from strands import Agent
from strands.models import BedrockModel

sandbox = MicroVMSandbox.create(
    ttl_seconds=3600,
    metadata={"app": "strands-analyst", "session": thread_id},
)

agent = Agent(
    model=BedrockModel(model_id="...", region_name="us-west-2"),
    sandbox=sandbox,
    system_prompt=ANALYST_PROMPT,
)

try:
    agent("Reconcile /workspace/ledger.csv against /workspace/bank.csv",
          session_id=thread_id)
finally:
    await sandbox.aclose()

Two details that are easy to miss. The agent registers the sandbox's vended tools itself at construction — you do not add them to the tools list — and it skips any whose name you already registered, so your own tool wins a collision rather than being shadowed by one you did not write. And the plain shell tool exported by the SDK, the one with no sandbox bound to it, resolves the sandbox from the agent at call time. That means a tool written against agent.sandbox works unchanged whether the agent is running against the host default in a unit test or against a microVM in production, which is exactly the seam you want.

Wrapping a synchronous SDK in asyncio.to_thread, as the adapter above does, is fine and it is what the SDK does internally for non-async tools. Just do not let the thread outlive the generator. If the consumer stops iterating early - a cancelled turn, an exception upstream - the worker thread is still streaming into a queue nobody reads. Bound the work with the execution timeout, and make cancellation kill the sandbox rather than politely asking the thread to stop.

Two things I did not verify and will not claim: whether the sandbox interface will stay source-compatible through 2.0 (the package carries deprecation notices for other tools targeting v2.0.0, so treat the adapter as something you own and will occasionally have to fix), and how the abstraction behaves under multi-agent orchestration where several agents might share one sandbox instance. If you are building a swarm, test the sharing semantics yourself before assuming them.

One sandbox per conversation

This is the decision that shapes everything else, and most people make it by accident. Three defensible answers:

  • Per tool call. Fresh machine every invocation, destroyed on return. Maximum isolation, zero state. Right for untrusted one-shots: a stranger pastes code, you run it, you throw the machine away. Wrong for analysis, because the DataFrame is gone by the next turn.
  • Per conversation. One sandbox created on first use, destroyed when the conversation ends. The model's mental model matches reality - it wrote a file two turns ago and expects the file to still be there. This is the right default for anything conversational and it is what the rest of this post assumes.
  • Per user, across conversations. Long-lived, with a home directory that survives. Lovely for a personal analyst where installed packages and past outputs should persist. Expensive, and it makes every mistake permanent: a user who fills their disk in March is still full in April. It also means that sandbox now holds everything that user has ever uploaded, which puts it inside your data retention policy whether or not anyone wrote that down.

Per-call gets chosen more often than it should because it sounds safest. It is safest, and it also makes the agent measurably worse: models reason about a Python environment the way a person does at a REPL, and an environment that silently forgets everything produces exactly the repetitive, slightly frantic behaviour you would expect from a person in the same situation. If you do need per-call isolation, say so in the docstring so the model plans for a cold start rather than being ambushed by one.

The cost argument for per-call is also weaker than it used to be. On a snapshot-restore platform a fresh microVM is back in roughly 179ms at p50, and billing is per second, so the machine sitting idle while the model thinks costs close to nothing. Choose per-conversation because it makes the agent better, not because it is cheaper.

import threading

_lock = threading.Lock()
_sandboxes: dict[str, MicroVMSandbox] = {}
_cells: dict[str, int] = {}


def sandbox_for(session_id: str) -> MicroVMSandbox:
    """Return this conversation's sandbox, creating it on first use."""
    with _lock:
        existing = _sandboxes.get(session_id)
        if existing is not None:
            return existing

        sbx = MicroVMSandbox.create(
            ttl_seconds=3600,
            metadata={
                "app": "strands-analyst",
                "session": session_id,
                # Tag whatever you will want to filter on at 2am: tenant,
                # user, deployment. "Forty sandboxes" is a number. "Forty
                # sandboxes and whose each one is" is an investigation.
            },
        )
        _sandboxes[session_id] = sbx
        _cells[session_id] = 0
        return sbx


def next_cell(session_id: str) -> int:
    with _lock:
        _cells[session_id] = _cells.get(session_id, 0) + 1
        return _cells[session_id]


async def close_session(session_id: str) -> None:
    with _lock:
        sbx = _sandboxes.pop(session_id, None)
        _cells.pop(session_id, None)
    if sbx is not None:
        await sbx.aclose()

In production that dictionary becomes something that survives a restart — Redis, or your session store — holding sandbox ids rather than objects. You then have two records that can disagree, yours and the platform's. Make the platform the source of truth about liveness, and treat "the sandbox I recorded is gone" as "create a new one" rather than as an error your user has to read.

This matters more than it sounds if you deploy behind an autoscaler. Your process-local dictionary is per-instance. Two turns of the same conversation landing on different instances will each create a sandbox, neither will see the other's files, and the symptom your users report is "the agent has amnesia every other message". Move the mapping into shared storage before you scale out, not after somebody files that bug.

Where the teardown hook is not

Strands has a typed hook system, and the natural instinct is to hang sandbox cleanup off it. Read the event names carefully first. AfterInvocationEvent fires at the end of an agent invocation — one call to the agent, one turn — not at the end of a conversation. Hanging kill() off it gives you per-call lifetime with extra steps, which is not what you meant.

What the hooks are genuinely good for at the tool boundary: per-turn accounting, collecting artifacts the turn produced, and rewriting tool results. AfterToolCallEvent exposes the result for modification, which is a reasonable central place to enforce an output budget across every tool rather than remembering to truncate in each one.

from strands.hooks import HookProvider, HookRegistry
from strands.hooks.events import AfterInvocationEvent, AfterToolCallEvent


class SandboxTelemetry(HookProvider):
    """Per-turn bookkeeping. NOT conversation teardown - see above."""

    def __init__(self, session_id: str) -> None:
        self.session_id = session_id
        self.exec_calls = 0

    def register_hooks(self, registry: HookRegistry, **kwargs) -> None:
        registry.add_callback(AfterToolCallEvent, self.on_tool)
        registry.add_callback(AfterInvocationEvent, self.on_turn_end)

    def on_tool(self, event: AfterToolCallEvent) -> None:
        if event.tool_use["name"] in ("run_python", "shell"):
            self.exec_calls += 1

    def on_turn_end(self, event: AfterInvocationEvent) -> None:
        metrics.gauge("agent.exec_calls_per_turn", self.exec_calls,
                      tags={"session": self.session_id})
        self.exec_calls = 0


agent = Agent(sandbox=sandbox, hooks=[SandboxTelemetry(thread_id)])

Conversation teardown belongs to whatever owns the conversation — your HTTP handler, your queue consumer, your WebSocket close callback. Strands does not know when your user has stopped talking, and it should not have to.

A related trap: Strands has session managers that persist conversation state to disk or S3, and they are good. They persist messages. They do not persist sandboxes. A conversation resumed from S3 three days later comes back with the full message history and a sandbox that was reaped two and a half days ago, and the model will confidently reference a file that no longer exists. Either re-seed the sandbox on resume, or put a line in the system prompt saying files do not survive a resumed conversation. Pick one deliberately.

Streaming output back while it runs

A thirty-second analysis with no output until it finishes reads as broken. Strands streams at two levels and you want both.

At the agent level, stream_async is an async iterator over the whole loop: text chunks as the model generates them, plus events telling you which tool is currently running. That is what drives the "running code..." indicator in your UI.

async for event in agent.stream_async(prompt, session_id=thread_id):
    if "data" in event:
        await ws.send_text(event["data"])          # model tokens

    tool_use = event.get("current_tool_use") or {}
    if tool_use.get("name") == "run_python":
        await ws.send_json({"type": "status", "text": "running code..."})

At the tool level, a tool written as an async generator yields intermediate events that surface through the same stream, so you can show the sandbox's stdout as it arrives rather than in one lump at the end. This is where the sandbox interface pays off again: execute_code_streaming is already a stream of typed chunks, so the tool is a loop.

from strands import ToolContext, tool
from strands.sandbox import ExecutionResult, StreamChunk


@tool(context="tool_context")
async def run_python(code: str, tool_context: ToolContext):
    """Execute Python in an isolated Linux microVM and return its output.

    (Same docstring rules as before - state, packages, paths, errors,
    output limits, network. Do not skimp on it because the body is async.)

    Args:
        code: Python source to execute. Include every import you use.
        tool_context: Injected by the framework. Not model-facing.
    """
    sbx = tool_context.agent.sandbox

    stdout_parts: list[str] = []
    stderr_parts: list[str] = []

    async for chunk in sbx.execute_code_streaming(code, "python3", timeout=60):
        if isinstance(chunk, StreamChunk):
            (stdout_parts if chunk.stream_type == "stdout"
             else stderr_parts).append(chunk.data)
            yield chunk.data          # intermediate: shows up in stream_async
        elif isinstance(chunk, ExecutionResult):
            # The LAST thing yielded becomes the tool result the model sees.
            yield shape(chunk)
The last value an async-generator tool yields is the tool result. Not the accumulated yields, not a concatenation - the final one. If your loop's last yield happens to be a raw output chunk because the result arrived earlier, that raw chunk is what lands in the model's context and the rest is decoration. Structure the generator so the shaped result is unambiguously last, and write a test that asserts it.

There is a second reason to prefer the streaming form beyond the UI: it gives you a place to stand while the code is still running. You can count bytes as they arrive and cut off a runaway printer at a megabyte instead of discovering it after the process has written four hundred. A tool that only sees output at the end has already paid for all of it.

Timeouts, exit codes and the OOM killer

Every execution gets a timeout. Not most of them — every one, including the ones you are sure are fast. The model will eventually write a while loop whose exit condition a bug prevents, or a nested iteration over a DataFrame that would finish some time next week, and without a timeout your agent turn simply never returns.

Choose the number against the layer above rather than against the code. If the agent runs behind an HTTP request with its own deadline, the execution timeout has to sit comfortably inside it, or your user gets a gateway error while the sandbox happily keeps computing and billing. Budget downwards from the outermost deadline, not upwards from a guess. Sixty seconds is a reasonable default for an analysis agent; the built-in shell tool defaults to 120, which is a fine default for a general tool and too long for a chat turn.

When a timeout fires, return it as a normal tool result — "the code exceeded the 60s limit and was killed" — rather than letting it blow up the turn. Models handle that well: they sample the data, add a limit, narrow the query. A generic "tool error" produces a blind retry of the identical code, which is the worst of both worlds.

Read the exit code, and do not throw it away

Here is a small thing worth knowing about the built-in shell tool: it returns a dict with output and error keys. Look at what is not in there. The exit code does not reach the model. A script that printed a partial result to stdout and then died at line 40 looks, from the model's seat, a lot like a script that finished. The model reasons over half an answer as though it were the whole one.

So put the exit code in your own tool result, explicitly, in words. And decode the ones that mean something specific:

  • 0 - finished. Empty stdout here means the model forgot to print, and saying so in the result saves a turn.
  • 1 - the usual Python exception path. stderr holds the traceback, which is the single most valuable thing you can hand back.
  • 124 - the conventional timeout code. Say the word "timeout" in the result and say what the limit was.
  • 137 - 128 plus 9: SIGKILL. In a memory-capped guest this is almost always the kernel OOM killer, and the giveaway is that stderr is empty. A process that died of memory does not get to write a traceback about it.
  • 139 - 128 plus 11: SIGSEGV. Usually a native extension, occasionally a genuinely deep recursion. Rare, and worth logging on your side rather than only telling the model.

The 137 case deserves its own sentence in the tool result, because it is the one the model reliably misreads. "Killed, exit 137, no output" reads to a model as a mysterious infrastructure failure and it will retry the identical code. "The process was killed for exceeding the sandbox memory limit — load the file in chunks or sample it" reads as an instruction, and the next attempt uses a chunksize argument. Same fact, one of them ends the conversation productively.

SIGNALS = {124: "timeout", 137: "out of memory (SIGKILL)",
           139: "segmentation fault"}


def explain_exit(result) -> str:
    if result.exit_code == 0:
        return "exit 0 (success)"
    label = SIGNALS.get(result.exit_code)
    if result.exit_code == 137:
        return ("exit 137 - the process was KILLED for exceeding the "
                "sandbox memory limit. There is no traceback because the "
                "kernel killed it. Process the data in chunks "
                "(pandas.read_csv(..., chunksize=...)), sample it, or "
                "aggregate in SQL instead of loading it all into memory.")
    if label:
        return f"exit {result.exit_code} ({label})"
    return f"exit {result.exit_code}"

One platform-specific note, since it bites people on snapshot-restore systems: the guest's RAM is fixed at the moment the template snapshot was baked. Firecracker cannot resize memory on restore, so passing a bigger memory_mb at create time does not stretch a baked snapshot — you pick the RAM by picking the template tier. Which means "give it more memory" is a deployment decision made once, not a per-request knob, and your OOM message should tell the model to change its approach rather than implying it can ask for more.

Cancellation, which everyone forgets

The user closes the tab. Your framework abandons the turn. The sandbox has no idea and keeps computing. This is a real cost leak and it is invisible in every dashboard except the bill.

Strands gives the tool a cancel_signal on the ToolContext — a threading.Event you can poll between steps, or forward to anything that accepts one. A tool that ignores it runs to completion regardless. For a code-execution tool the correct response to cancellation is not to poll politely; it is to stop the work at the source.

    # inside the tool, between steps or in the streaming loop
    if tool_context.cancel_signal.is_set():
        # Do not just return - the interpreter inside the VM is still
        # burning CPU. Kill the process, or kill the machine.
        vm.exec("pkill -9 -f '/workspace/cells/' || true", timeout_seconds=5)
        return "status: cancelled\nExecution was cancelled by the caller.

Per-conversation sandboxes make this less urgent than it sounds, because the abandoned process lives inside a machine you are already tracking and closing the conversation destroys both. But "the idle sweeper will get it in an hour" is an hour of billed CPU, and if the runaway is a tight loop it is an hour of a core you could have sold to somebody else.

The shape of the result matters more than the result

The most expensive mistake in this whole design is also the most boring one. The tool runs code, the code prints a DataFrame, and two hundred kilobytes of numbers go straight into the model's context. You pay for those tokens on this turn and on every subsequent turn of the conversation, the model's attention is spread across a wall of digits it cannot use, and the answer gets worse, not better.

So truncate. But truncate correctly, which means keeping both ends. The head has the column names, the shape, the first rows — the structure. The tail has the answer, because whatever the code printed last is usually what it was computing. Keep only the head and you lose the result; keep only the tail and you lose the schema. Keep both, with an explicit marker between them so the model knows something was removed and does not reason as though it saw everything.

MAX_CHARS = 6000
HEAD = 2000
TAIL = 3000


def clamp(text: str, label: str) -> str:
    text = (text or "").strip()
    if len(text) <= MAX_CHARS:
        return text
    omitted = len(text) - HEAD - TAIL
    marker = (
        f"\n\n... [{omitted} characters of {label} omitted. Re-run with a "
        f"filter, .head(), .describe() or a summary if you need the "
        f"middle - do not ask for the whole thing again.] ...\n\n"
    )
    return text[:HEAD] + marker + text[-TAIL:]


def shape(result) -> str:
    """Turn an execution into a block a model can act on."""
    stdout = clamp(result.stdout, "stdout")
    stderr = clamp(result.stderr, "stderr")

    if result.exit_code == 0:
        return (
            "status: ok\n"
            f"{explain_exit(result)}\n"
            "--- stdout ---\n"
            + (stdout or "(nothing was printed - remember that only "
                         "print() output is visible to you)")
            + (f"\n--- stderr ---\n{stderr}" if stderr else "")
        )

    return (
        "status: error\n"
        f"{explain_exit(result)}\n"
        "--- stderr ---\n"
        + (stderr or "(no stderr)")
        + (f"\n--- stdout before it failed ---\n{stdout}" if stdout else "")
        + "\n--- next step ---\n"
          "Read the error above, fix the code, and call the tool again. "
          "Do not report this failure to the user unless you have tried "
          "at least once to correct it."
    )

Plain text, not JSON. Both work — Strands JSON-serialises any non-string return value before it reaches the model — but a labelled text block costs fewer tokens than the same content wrapped in braces and quotes, and models read it at least as well. If you prefer structure, the SDK also accepts the full tool-result form, a dict with status and content keys where content is a list of text or json blocks, which is what you want when part of the result is genuinely structured data and part of it is prose.

The other half of this is what happens on failure, and it is the highest-leverage decision in the tool. Return the traceback. Do not raise.

Strands is more forgiving here than most frameworks: it catches exceptions out of a tool and converts them into an error result, so a raise does not blow up the run. But look at what the model actually receives — a single line of the form "Error: KeyError - 'total_amount'". That is the exception type and its message, and nothing else. No traceback, no line number, no context. Compare that to handing back the full stderr, where the model sees which line failed and what the surrounding code was doing. Models fix their own KeyError and NameError at a rate that would embarrass most humans, but only when they can see where it happened. The framework's safety net keeps you running; it does not make your agent smart.

Two caveats on the errors-as-data pattern. Put a ceiling on it — count attempts per turn and stop after three or four, or an impossible task becomes an expensive argument between a language model and an ImportError. And shape the error before returning it. The tool boundary is the right place to strip anything from your infrastructure you would rather not see quoted back in a chat transcript, because everything in the tool result is one clever prompt away from being read aloud to the user.

Files in, files out

Sooner or later the agent makes a chart. The wrong instinct — and I have watched several teams have it — is to base64 the PNG into the tool result. Forty megabytes of base64 in a transcript is a bad afternoon: it blows the context window, it costs real money, and the model cannot see the image anyway unless you have separately wired up multimodal input.

Bytes through the filesystem, paths through the model. That is the whole rule and it runs in both directions.

async def seed_input(sandbox: MicroVMSandbox, upload: bytes) -> str:
    """Put the user's file in the VM BEFORE the agent asks for it."""
    await sandbox.write_file("/workspace/data.csv", upload)
    return "/workspace/data.csv"


async def collect_artifacts(sandbox: MicroVMSandbox) -> list[dict]:
    """Pull whatever the agent wrote to /workspace/out back out."""
    try:
        entries = await sandbox.list_files("/workspace/out")
    except FileNotFoundError:
        return []

    artifacts = []
    for entry in entries:
        if entry.is_dir:
            continue
        data = await sandbox.read_file(f"/workspace/out/{entry.name}")
        artifacts.append({
            "name": entry.name,
            "bytes": len(data),
            "url": await upload_to_object_store(entry.name, data),
        })
    return artifacts

Note that the input direction matters as much as the output. If the user uploaded a CSV, write it into the sandbox and tell the agent the path in the system prompt. Pasting rows into the context means you pay for every row twice — once going in, once when the model quotes them back — and the model will transcribe at least one number wrong, which is a worse failure than an expensive one because it looks like an answer.

The ExecutionResult type has an output_files field for exactly this, and it is worth knowing what it is for. Shell-based sandboxes leave it empty, because a shell has no notion of "this command produced an artifact". A Jupyter-backed or API-backed sandbox can populate it, so if you are running a persistent kernel that produces inline charts, that is where they belong rather than in the text stream.

Cleanup: three layers, and you want all three

Each layer covers a different way the one above it fails.

  1. An explicit destroy when the conversation ends. Handles the happy path, and it is the only one that frees the resource promptly.
  2. An idle sweeper that reaps sandboxes whose conversations have not been touched in a while. Handles users who close the tab without saying goodbye, which is the overwhelming majority of them.
  3. A TTL set at creation, enforced by the platform. Handles the day your process is OOM-killed mid-conversation and neither of the first two runs.

If you only implement one, implement the TTL. It is a single argument to create() and it is the only layer that survives your own code being dead. Everything above it is an optimisation that releases resources sooner and saves money; the TTL is what stops a leak from being unbounded.

Set it generously enough that it never truncates a real conversation — an hour is usually right for a chat agent — and treat it as a backstop rather than a session limit. If your TTL is doing the reaping in normal operation, your cleanup path is broken and the TTL is hiding it.

When all of this is overkill

I sell microVMs, so read this section with that in mind, and then believe it anyway, because a post that says "use us" to everyone is worth nothing.

If you are the only person who can influence your agent's context — a local research assistant, a script you run against your own data, an internal tool behind SSO where every user is an employee with a laptop that already has production credentials on it — then the microVM is buying you very little. The threat model for "my agent might run bad code on my own machine" is mostly about accidents, and Strands' Docker sandbox handles accidents fine with one line of config and no new vendor. Use it. Point it at a container with the packages you need, give it a working directory, and get on with the actual product.

Even the host default has a legitimate place: unit tests, a notebook, a five-minute spike where the only thing you are testing is whether the model calls the tool at all. Just do not let that spike become the deployed thing without somebody re-reading the class name.

The line where the calculus changes is when someone who is not you can influence the string that gets executed. That includes the obvious cases — a product where users chat with the agent, a support bot, anything public — and the less obvious ones, which are the ones that actually bite: the agent that reads web pages, the agent that ingests customer-uploaded spreadsheets, the agent that summarises inbound email, the agent whose RAG index contains documents your customers wrote. In every one of those, a stranger has partial write access to the model's context, and therefore partial write access to the code.

The other side of the line is compliance. If you are handling regulated data and someone is going to ask you to describe the isolation boundary in a questionnaire, "a container on the same kernel as the orchestrator" is an answer you will have to defend and "a separate kernel per tenant execution" is one you will not. That is not a technical argument, it is a procurement one, and it is often the argument that actually decides.

Before you ship it

  1. The code runs somewhere with its own kernel, for any agent whose context includes text you did not write.
  2. The sandbox holds no credentials it does not need. Pass the one token the task requires, if any, rather than forwarding your environment.
  3. The session id comes from invocation_state via ToolContext, never from a model-supplied argument.
  4. The docstring says what persists, what is installed, where files go, whether there is network, and that errors come back as text to be fixed.
  5. Every execution has a timeout, budgeted inside the deadline of whatever is calling you.
  6. The exit code reaches the model in words, with 137 explained as memory rather than left as a mystery number.
  7. Output is truncated head-and-tail with a marker, before it enters the context.
  8. Errors return the full stderr as data, bounded by a retry ceiling - not a bare exception the framework flattens to one line.
  9. Bytes go through the filesystem and paths go through the model. Nothing is base64'd into a transcript.
  10. Destroy on conversation end, sweep on idle, and set a TTL for the day neither of those runs.

Almost none of that is Strands-specific. Swap the decorator and the same tool body works in LangChain, Pydantic AI or Google's ADK — which is handy, because Strands' surface will have moved by the time you read this and the decisions will not have. The one genuinely Strands-shaped idea worth keeping is the sandbox interface: implement it once and every tool in the ecosystem inherits your isolation, which is a much better deal than wrapping each tool by hand.

Frequently asked questions

Does the Strands Agents SDK have built-in sandboxing?

Yes, as of the 1.x line it ships a sandbox abstraction: the Agent constructor takes a sandbox argument, there is an abstract Sandbox base class, and built-in tools like shell and file_editor route through whichever sandbox the agent holds. Concrete implementations for Docker and SSH are included. If you do not pass one, the agent falls back to a class named NotASandboxLocalEnvironment, which runs commands directly on the host with no isolation - the name is a deliberate warning. Docker is a genuine improvement over the host default and the right choice for many teams, but it shares your host kernel; for code influenced by people who are not you, a separate kernel is the boundary worth having. Verify the current interface against strandsagents.com, as this area is newer than the rest of the SDK.

How do I write a code execution tool in Strands?

Decorate a Python function with @tool from the strands package. The function signature becomes the parameter schema, the type hints become the types, and the docstring becomes the description the model reads. For a code tool that means the docstring is doing real work: state whether variables persist between calls, which packages are installed, what the working directory is, whether there is network access, and that errors come back as text to be corrected rather than reported. To get framework-provided context such as the conversation id, pass context="tool_context" to the decorator and add a tool_context parameter; that parameter is excluded from the schema the model sees, which is exactly why the session id belongs there rather than in a model-supplied argument.

Should each Strands conversation get its own sandbox, or each tool call?

Per conversation, for anything conversational. Models reason about a Python environment the way a person does at a REPL: they write a file in one turn and expect it in the next. A fresh sandbox per call breaks that assumption silently and you get an agent that re-parses the same CSV every turn, burning tokens and occasionally producing inconsistent results. Per call is right for genuinely untrusted one-shot submissions where you want maximum isolation between them. If you choose per call, say so in the docstring so the model plans for a cold environment. On a snapshot-restore platform a fresh microVM comes back in roughly 179ms at p50, so the choice is about agent quality rather than speed.

What does exit code 137 mean from an agent's code execution tool?

137 is 128 plus 9, meaning the process received SIGKILL, and in a memory-capped sandbox that is almost always the kernel out-of-memory killer. The giveaway is that stderr is empty: a process killed for memory does not get to write a traceback. Do not pass the bare number to the model, because it reads as a mysterious infrastructure failure and the model will retry identical code. Say in words that the process was killed for exceeding the memory limit and suggest the fix - read the file in chunks, sample it, or aggregate before loading. On snapshot-restore platforms the guest's RAM is fixed at the time the template snapshot was baked, so more memory is a deployment decision rather than a per-request knob.

How much output should a code execution tool return to the model?

A few thousand characters, keeping both ends. The head carries the structure - column names, shapes, the first rows - and the tail carries the answer, because whatever the code printed last is usually what it was computing. Drop the middle and insert an explicit marker so the model knows something was removed and does not reason as though it saw everything. Never let a full DataFrame dump reach the context: you pay for those tokens on this turn and every turn afterwards, and the answer gets worse rather than better. Keep stderr verbatim inside the budget, because a traceback is the single most useful thing the tool can hand back.

Should a Strands tool raise an exception when the generated code fails?

No. Return the failure as data. Strands does catch exceptions from a tool and convert them into an error result, so a raise will not end the run, but look at what the model receives: a single line of the form "Error: KeyError - 'total'". That is the exception type and message with no traceback, no line number and no context. Returning the full stderr yourself gives the model the failing line and the surrounding code, which is what it needs to fix itself - and models correct their own NameError and KeyError with high reliability when they can see where it happened. Add a retry ceiling of three or four attempts per turn, and shape the error before returning it so nothing from your infrastructure gets quoted back in the chat.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.