Skip to Content
DocsToolsCode mode

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 real async callable; only what the code print()s or assigns to result comes 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]: ...
ParameterDefaultDescription
host_or_toolsrequiredAn existing ToolHost (permission / sandbox wrappers included — inner calls go through it) or a plain list of tools/callables, which gets wrapped in an InProcessToolHost.
executorNoneNone = 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_s30.0Hard 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 await is allowed.
  • Output = the result variable (or captured print()), 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.

TierWhat you getWhat you don’t
In-process (executor=None)Accident guard; permission hooks see every inner tool callNo isolation of any kind
SubprocessExecutorProcess isolation, hard kill on timeout, minimal env (no host secrets), Python -INo 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 backendNot 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.

Last updated on