Code mode

Updated Nov 4, 2026View as Markdown

Code mode gives the model one more tool, run_code. The model writes JavaScript that calls the agent's tools, and gets back only what the script returns or logs.

Use it when the model chains many calls, runs calls that do not depend on each other, or reads large results to pick out a few values. Each call in a script costs no model turn, and its result stays out of the conversation.

Turn it on

subs.toml
[agent.assistant]
model = "claude-sonnet-5"
codemode = "javascript"

A worker sets codemode: "javascript" on the agent config. JavaScript is the only language so far.

What the model writes

run_code takes code, the body of an async function.

const prs = await tools.github__list_prs({ repo: "acme/api", state: "open" });
const stale = prs.filter((pr) => Date.parse(pr.updated_at) < Date.now() - 14 * 864e5);
const reviews = await Promise.all(stale.map((pr) => tools.github__get_reviews({ number: pr.number })));
return stale.map((pr, i) => ({ number: pr.number, reviews: reviews[i].length }));

Code mode is all or nothing. The model gets one tool, run_code, and reaches every other tool from a script. The agent has no call_tool or attachment tools, and a direct call to any tool but run_code is refused with an error that says to call it from run_code.

The description of run_code declares each tool the script can call in TypeScript: the first line of its description as a comment, its input type and, when the tool declares an output schema, its result type. Each definition a schema refers to with $ref becomes a named type, declared once, so a recursive schema stays finite. The declarations stop at 12 KiB. The description holds nothing that changes while a session runs, so the cached prefix holds.

A script finds the other tools, past the budget or deferred, with tools.tool_search. The conversation lists each skill with the tools it names, in the system prompt on the first call. A skill added later is listed when it is, after what the model has read. In code mode a skill holds back no tools: its tools says which tools it is about.

A script finds tools with tools.tool_search({ query }). It answers as text: the declarations of the tools that match, each with its whole description, then the other matches by name, then the skills that match, with how to load them. A query of exact tool names, as "get_audience, find_customers", answers just those tools. A block of declarations, in a search answer, a skill's answer or the description of run_code, holds at most 12 KiB, but its first tool is always declared, so a search for a tool's name always finds its declaration. The model reads the answer, then calls the tools in its next script. A call to a tool that does not exist throws an error that names the closest tools.

A call returns the tool's result, parsed when it is JSON. A call that fails throws an Error with the tool's message.

What a script can call

A script can call the agent's worker tools, connector tools, client tools, subagents, skill and tool_search. A call that needs approval waits for a person, and a client tool waits for the client, as a direct call does. A subagent call returns { session, result }, as a direct call does. A script calls tools by name, and reads files with files. A call to run_code, or to a tool that does not exist, throws, and the script can catch it. A call is checked against the tool's input schema before it runs, and its result against the tool's output schema, as a direct call is.

await tools.skill({ name }) loads a skill. It answers with the skill's instructions, then the declarations of the tools it names. The script logs the answer, and the model can then call those tools in its next script without a search. When the agent searches its skills, tools.tool_search finds them too.

A skill's other files are files. A script reads them by <skill>/<path>, such as files.text("runbooks:respond/references/escalation.md"), and shows them with files.show. files.list lists them with the skill they belong to, as skill. The script's first step records the blobs that hold the skills' files, and every later run reads those, so a plugin that changes while the script runs does not change what it reads.

What reaches the model

The run_code result is text: Script finished., what the script printed, then the value it returned, a string as itself and any other value as JSON.

Script finished.
Printed:
3 stale PRs
Returned:
[{"number":12,"reviews":0},{"number":15,"reviews":2}]

Past 24 KiB, the middle is cut out with a note, so the start and the end stay. A script that throws gives Script failed., the error and the lines of the script in its stack, then what it printed. Then comes each file the script showed.

files.show(id) puts a file in front of the model: an image, PDF, audio or video as itself, and any other file as its text. A script shows at most 4 files, 64 KiB of file text and 2 MiB of media, which bounds what a file can cost in context. Text the script makes is printed with console.log.

How a run works

When a script stops on calls, the engine asks your worker about them with a codemode.calls trigger, as it asks about the model's calls on llm.finished.

{ "type": "codemode.calls",
  "id": "<run_code call id>",
  "tool_calls": [
    { "id": "call_s1", "type": "function",
      "function": { "name": "weather", "arguments": "{\"city\":\"Oslo\"}" } }
  ] }

proposed runs each call with tool.call, or starts a subagent with subagent.spawn. When a call needs approval, it asks a person about that call first, and the rest wait. When a subagent finishes, subagent.finished proposes its answer as the call's tool.result. Return proposed, or answer each call by its id: tool.call runs it, and tool.error refuses it with your message. A call that no answer runs or refuses fails, and the script can catch the error. A worker that returns proposed for triggers it does not know needs no change.

Each call a script makes is a tool call in the session log, under the run_code call. It runs as a direct call does, with the same retries and deadlines. The model does not see it in the transcript.

The engine does not keep a script running while it waits. When the calls a script made have settled, the engine runs the script again from the start. A call with a recorded result gets that result at once. The run stops at the first call with no result, and the engine makes that call. A restart loses nothing, because the log holds every result.

So a script must make the same calls each time it runs. Date and Math.random give the same values on every run. A run that makes a different call fails.

run_code has no deadline of its own. Its calls keep theirs, and the script's limits bound what it runs. An interrupt holds a script: no step runs, and no call it asks for goes out, until the resume.

Files

A script reads the conversation's files by the id in their [attachment …] line. The bytes never enter the model's context: the script reads them, and only what it returns or prints goes back.

const totals = {};
for await (const row of files.csv("sales.csv")) {
    totals[row.region] = (totals[row.region] ?? 0) + Number(row.amount);
}
return totals;
CallGives
files.list(), files.stat(id){ id, mime, size } of each file, or of one, and skill for a skill's file
await files.text(id)The whole file as text, up to an eighth of memory
files.show(id)Nothing; the model sees the file when the script ends
files.csv(id, { header, delimiter })Rows: objects keyed by the header, or arrays with header: false
files.lines(id)Lines, without their line ends
files.jsonl(id)One JSON value per line
files.chunks(id, { size, offset })Text in pieces of about size bytes, from byte offset
files.xlsx(id, { sheet, header })Rows of an xlsx sheet, the first by default, like files.csv. Numbers and booleans stay as they are, dates become ISO strings, and empty cells are null
await files.sheets(id)The names of an xlsx file's worksheets

Each stream takes { limit, offset }, counts of records to read and to skip, and break ends it. A CSV or xlsx header, blank JSON lines and empty sheet rows are not counted. files.chunks counts its offset in bytes. The files are the attachments on the branch where run_code was called.

The script's process decodes the files, so decoding counts toward its CPU and memory. Its runner reads the bytes: the process has no file system or network, and never sees where a file is stored. A file's bytes never change, so the log records no read: each run reads its files again. Read files before calling tools, so the reading happens once per run.

A file that is gone throws in the script. A store that fails sends the step back to its queue, as a runner that is down does.

RuntimeConfig::script_blob_cache puts a disk cache in front of the store that scripts read from. It is worth it when the store is remote, as object storage is.

Sandbox

Each run of a script is a new process that the engine starts from its own binary. Before the process reads the script, it closes every file it inherited, starts with no environment, drops to nobody when it runs as root, and locks itself down: on macOS with a sandbox profile that denies everything, and on Linux with a seccomp filter that has no call to open a file, open a socket or start a process. A process that cannot lock itself down does not run the script. The engine kills a process that runs past its deadline.

SUBS_SANDBOX_SLOTS sets how many scripts subs runs at once. The default is the number of CPUs, from 2 to 8. A tenant may take every slot while no other tenant waits; while another does, it keeps at most all but one.

Run scripts somewhere else

A program that embeds the engine chooses what runs scripts with RuntimeConfig::script_runner, an implementation of substructure_host::codemode::ScriptRunner. A runner takes one step of a script, a Script, and returns where it ended, a Step. It keeps nothing between steps, so it can be a process here, another machine, or a service. The engine still decides how many steps run at once, reads each step from the session, and checks every call a step asks for.

A runner that cannot take a step now, because it is down, busy or of another version, returns a RunError. The engine puts the step back on its queue and tries it again a few seconds later, until the step's queue timeout. A script that fails is a Step::Failed, not an error.

The default runner, ProcessRunner, starts this binary as a confined process for each step. That binary must call substructure_host::codemode::dispatch() first in main, before it starts a thread. Without it, every script fails with an error that says so.

The process speaks JSON lines on stdin and stdout. It first writes {"ready": {"version": N, "oldest": M, "languages": [...]}}, or {"unconfined": {"error": "..."}} when it could not confine itself. Then it reads one Script and writes one Ran. While it runs, it may ask for file bytes with {"read": {"file": "sales.csv", "offset": 0, "len": 262144}}, and its runner answers {"bytes": n} followed by n raw bytes, or {"missing": "…"}. A runner reads a script's files from the ScriptFiles the engine passes it: each file's blob and the store to read it from.

A runner on another machine can start the same binary with __sandbox the same way, behind its own endpoint. Scripts that read no files need nothing more. For files, the process that starts the binary answers its reads, so a runner on another machine needs the blobs too: it reads them from the store, or the engine answers the reads for it. The engine does not provide either yet. A Script carries its version, and a runner takes every version from OLDEST to VERSION, so an engine and its runners can be deployed one before the other.

Script steps wait on RuntimeDeps::script_task_queue.

Add a language

Nothing outside three places knows which language a script is in:

  • CodeLanguage, the enum codemode takes, in session::codemode.
  • A dialect in session::codemode, which tells the model how to write run_code in that language and declares each tool in its types. javascript.rs declares tools in TypeScript.
  • An engine in host::codemode, an implementation of Engine, listed in engine.rs. It interprets the language and asks Replay for each call the script makes. Replay answers from the script's records, holds it to its limits, and turns how it ended into a step, so an engine only binds the language: a call to a tool, printed output, the value returned, and an error raised. quickjs.rs is the JavaScript engine.

A runner process says which languages it runs when it starts, and a step in a language it does not run is tried again elsewhere.

Limits

A script has no network, file system, timer or module. The engine asks RuntimeConfig::script_limits for a tenant's limits at each step, and a runner holds each script to them. An error names the limit that was met and what to do. The 24 KiB of text a run sends back is not among them: the model is told it, so the session sets it. Files a script shows have their own budget, shown and shown_media.

LimitDefaultApplies to
calls1,000Calls over the whole script
results8 MiBResults the script reads, over all its calls
arguments64 MiBArguments over the whole script
call_arguments4 MiBArguments in one call
step_arguments8 MiBArguments of the calls one step asks for, together
memory256 MiBJavaScript heap in one run
compute_ms5,000CPU in one run
total_compute_ms120,000CPU over all of a script's runs
file_bytes64 MiBFile bytes one run reads
total_file_bytes512 MiBFile bytes over all of a script's runs
streams4Files open at once
shown_files4Files the script shows the model
shown64 KiBFile text the script shows the model
shown_media2 MiBMedia files the script shows the model

subs.toml sets them in a [codemode.limits] table. Sizes take bytes or a word such as "4mb", and a limit left out keeps its default:

[codemode.limits]
call_arguments = "4mb"
memory = "512mb"
compute_ms = 5000

The runner's process heap is twice memory, and at least 512 MiB.

A run reads the script, the results its calls already returned, and the files it asks for, and stops at the first call with no result. Time spent waiting on file reads does not count toward its deadline. So a run is quick, and a run that needs more than five seconds of CPU is almost always a runaway. A script resumes from the start after its calls finish, so work done before a call counts against each run and against the whole budget. A run's process is stopped three times its CPU allowance and one second after it starts, and the operating system stops it a second past its allowance. RuntimeConfig::script_wait, 60 seconds by default, is how long a step may wait for a slot or for a runner to take it. ProcessRunner::with_data_limit caps a process's heap, records included; keep it above memory. Parsed JSON takes about seven times its size in heap, so keep results under an eighth of memory.

Each step logs a script step line under the codemode target, with its tenant, the bytes of results it was given, its wait, its run time, its heap and CPU, and the limit it met, if any. A step that uses over half its memory or compute logs a warning.