Code mode
Code mode lets the model call tools by writing Python instead of
emitting one tool_use block per call. Instead of N tool schemas in
context, the agent carries exactly two tools:
search_api(query)— returns typed Python signature stubs for matching tools, so the model discovers the API progressively.run_code(code)— executes the model’s Python. Each underlying tool is bound as a realasynccallable; only what the codeprint()s or assigns toresultcomes back (capped at 20K chars).
from loomflow import Agent
from loomflow.tools.code_mode import make_code_mode_tools
agent = Agent(
"You are a data analyst.",
model="claude-opus-4-7",
tools=make_code_mode_tools([query_db, fetch_metrics, list_tables]),
)The model writes:
rows = await query_db(sql="SELECT * FROM orders WHERE region = 'EU'")
result = len(rows)and sees "8231" — not the 50K tokens of rows. That is the point:
intermediate tool results stay out of the model’s context; the
model filters and aggregates in code and pays only for the answer.
Why code mode
The classic multi-tool loop has two token sinks: the tool-definition
block (addressed by tool search) and
intermediate results — a tool returns 50K tokens of JSON, the
model reads all of it, extracts one number, and you paid for the
round-trip twice. Code mode collapses chains of calls into one
run_code invocation whose intermediate values live in Python
variables, and whose loops and joins are real for loops instead of
model turns.
make_code_mode_tools(...)
def make_code_mode_tools(
host_or_tools: ToolHost | Sequence[Tool | Callable],
executor: CodeExecutor | None = None,
*,
module_name: str = "tools",
timeout_s: float = 30.0,
) -> list[Tool]: ...| Parameter | Default | Description |
|---|---|---|
host_or_tools | required | An existing ToolHost (permission / sandbox wrappers included — inner calls go through it) or a plain list of tools/callables, which gets wrapped in an InProcessToolHost. |
executor | None | None = in-process tool-binding mode. A CodeExecutor (e.g. SubprocessExecutor()) = out-of-process data-transform mode without tool bindings. |
module_name | "tools" | Namespace the stubs advertise. Inside run_code each tool is bound both as a bare name (await read(...)) and as an attribute (await tools.read(...)). |
timeout_s | 30.0 | Hard timeout per run_code call. |
Returns [search_api, run_code] — pass them to Agent(tools=...).
run_code is marked destructive=True (it is arbitrary code and can
invoke destructive tools), so default permissions route it through
the approval handler.
Mode 1 — in-process tool binding (default)
With executor=None, code runs via exec inside the agent process.
Every tool from the host is available as an async shim that routes
through host.call(...), so permission and hook layers wrapped
around the host still see every inner call. The environment is
deliberately spartan:
- Restricted builtins — no
open,eval,exec,globals. - Imports limited to
json,re,math,datetime. - Top-level
awaitis allowed. - Output = the
resultvariable (or capturedprint()), capped at 20K chars with an explicit truncation marker.
# What the model discovers via search_api("weather"):
# async def get_forecast(city: str, days: int = ...) -> Any:
# """Fetch the weather forecast. ..."""
# What it then runs:
temps = []
for city in ["Berlin", "Madrid", "Oslo"]:
fc = await get_forecast(city=city, days=3)
temps.append((city, fc["daily"][0]["high"]))
result = max(temps, key=lambda t: t[1])Three tool calls, arbitrary Python glue, one tool result in context.
Mode 2 — out-of-process data transform
Pass an executor and the code runs in a separate process without tool bindings — pure computation with process isolation, a hard timeout, and a minimal environment. Use it to let the model crunch untrusted data (parse, aggregate, transform):
from loomflow.tools.executor import SubprocessExecutor
from loomflow.tools.code_mode import make_code_mode_tools
tools = make_code_mode_tools(
[load_csv_tool],
executor=SubprocessExecutor(),
)In this mode the code must print() what it wants returned (the
result variable is not read), and file outputs written under
./out/ come back as artifacts. SubprocessExecutor runs Python
with -I (isolated mode) in a fresh scratch directory, forwards only
a minimal env allowlist (host API keys and tokens are not
handed to model-authored code), and kills the process on timeout.
Out-of-process code with tool calls round-tripping back to the host is future work — v1 ships the two simple, correct halves of the split.
The CodeExecutor protocol — your E2B / Modal / Docker seam
The isolation boundary is one tiny async method:
class CodeExecutor(Protocol):
async def run(
self,
code: str,
*,
language: str = "python",
timeout_s: float = 30.0,
files: Mapping[str, bytes] | None = None,
env: Mapping[str, str] | None = None,
) -> ExecResult: ...ExecResult carries stdout, stderr, returncode, timed_out,
and artifacts (files the code wrote under ./out/, budget-capped).
Implement run() against E2B, Modal, Daytona, Docker, or gVisor and
pass the instance anywhere a CodeExecutor is accepted — that is the
protocol’s whole purpose. The same seam powers the built-in shell
tool: bash_tool(executor=my_executor) routes shell commands through
your sandbox instead of a local subprocess.
Security tiers — be honest about what you get
In-process mode is not a sandbox. The restricted-builtins /
limited-imports environment is an accident guard, not a security
boundary — in-process code is exactly as trusted as any direct tool
call (same process, same memory; a determined payload can escape a
builtins allowlist). One more caveat: the in-process timeout cancels
at await points, so model code that busy-loops synchronously cannot
be interrupted.
| Tier | What you get | What you don’t |
|---|---|---|
In-process (executor=None) | Accident guard; permission hooks see every inner tool call | No isolation of any kind |
SubprocessExecutor | Process isolation, hard kill on timeout, minimal env (no host secrets), Python -I | No network / filesystem / syscall restriction |
OSSandbox (run the agent under it) | Kernel-enforced FS + process isolation (Seatbelt / Bubblewrap / Landlock) | Setup per platform — see Sandboxes |
Remote CodeExecutor (E2B, Modal, Docker — you implement) | Full isolation at the strength of the backend | Not shipped; implement the one-method protocol |
Pick the tier that matches how much you trust the inputs the model reads, not how much you trust the model.