| layout | page |
|---|---|
| title | JavaScript Sandbox |
| permalink | /javascript-sandbox |
| description | How NodeTool runs untrusted JavaScript in a QuickJS WebAssembly guest — the capability surface, the limits, the module system, and how the Code node and CodeAct agents use it. |
Navigation: Architecture | Chat & Agents | CodeAct design | Sandbox packages
Every piece of JavaScript NodeTool did not write itself runs in one place: a
QuickJS WebAssembly guest built by runInSandbox
(packages/agents/src/js-sandbox.ts). A workflow's Code node, an agent's code
action and a planner's graph program all enter through that function, and they all get the same engine, the same limits, and the same
marshaling rules.
The guest has its own heap inside the WASM instance, so a runaway or hostile
program cannot corrupt the host V8 heap the way it could under node:vm. What
it can reach is a curated set of host bridges, and each one is a capability the
caller granted for that run.
| Caller | Code | What runs |
|---|---|---|
nodetool.code.Code |
packages/code-nodes/src/nodes/code-node.ts |
A user's node body, with dynamic inputs on inputs |
| CodeAct step / chat turn | packages/agents/src/codeact/ |
One model-written action per execute_code call |
validate_workflow with code |
packages/agents/src/graph-dsl.ts |
A legacy graph DSL program with no host access at all |
| Browser runner | packages/workflow-runner/ |
The same Code nodes, in the page, fetching modules over HTTP |
One engine (loadQuickJs, the quickjs-ng release variant) is loaded once per
process and shared. Each invocation gets a fresh runtime and context.
- Resolve limits.
resolveSandboxLimitsapplies defaults and clamps every caller override to a hard ceiling. A caller can tighten a limit, or raise it within bounds, but never switch a protection off. - Build the bridges.
buildSandboxconstructs the host-side objects (fetch,workspace,crypto, …) bound to this run's context, limits and abort signal. Each async bridge is wrapped inneverRejectandguardAbort. - Build the entry module.
buildEntryModuleparses the code with acorn, hoists staticimportdeclarations above the wrapper, and emits the rest as the body of a top-level-awaited async IIFE, soreturn valuebecomes the module's default export. Code acorn cannot parse falls through towrapCodeunchanged, so a syntax error reaches the user as they wrote it. - Install the module loader — always, because it is also what the wrapper's
own Node-compat preamble resolves through. That preamble compiles ~12KB of
polyfills into every fresh runtime, and the prelude below deletes most of what
they install, so
BOOTSTRAP_MODULE_SOURCES(sandbox-bootstrap-modules.ts) serves four of them as empty modules andnode:utilas the two classes the guest keeps.node:urlstill comes from the wrapper:URLandURLSearchParamsare guest capabilities. Measured: 5.96ms → 3.28ms of setup per run. Guest specifiers resolve only what the run declares; withoutmodules, every guestimportresolves nothing. - Init prelude.
eval,Functionand the wrapper's unconditional stubs (Buffer,process,env,Headers,Request,Response,performance) are deleted. Four of those no longer exist to delete — the bootstrap above never installs them — and the deletions stay as the guard for a wrapper release that puts them back some other way. The entry module additionally deletes the timer globals, which the wrapper library re-installs on every evaluation. - Run, under an interrupt handler on a CPU deadline and a wall-clock race.
- Serialize. The guest encodes its result with the JSON transport (see
Marshaling rules) and the host decodes it, then
serializeResultwalks the returned value (cycle-safe, depth-capped at 32) converting typed arrays at any depth, then truncates tomaxOutputSize. Object-typed globals are deep-replaced on the host, which is how the Code node'sstatesurvives between invocations.
Two kinds of thing, and the difference matters: capabilities are globals, libraries are imports.
| Global | What it does |
|---|---|
console.* |
Log lines land in the run's logs array, capped by count and bytes (MAX_COLLECTED_CONSOLE_LOGS / MAX_COLLECTED_CONSOLE_CHARS in js-sandbox.ts) with one notice line when cut. A Code node also posts each line as a log_update |
fetch(url, options?) |
HTTP, returning {ok, status, headers, body, json, text(), arrayBuffer(), bytes()}. A Uint8Array body is sent as raw bytes |
workspace.* |
read, write, list, readBytes, writeBytes, stat, root, copy, move, mkdir, remove. Needs a ProcessingContext |
getSecret(name) |
The run's secret store, limited to the run's declared secret scope |
nodetool.secrets.* |
get (throws when unset), tryGet, list — over the same bridge |
sleep(ms) |
The only timer |
crypto.* |
randomUUID, getRandomValues, digest, hmac (WebCrypto-backed; SHA-1/256/384/512) |
format.* |
number, date, relativeTime, list — host Intl, which QuickJS does not ship. All four are async |
image.* |
info, stats, decode, blank, pad, grid, resize, crop, rotate, flip, adjust, composite, convert. Transforms return image handles |
audio.* |
info, normalize, trim, concat, mix, reverse, fadeIn, fadeOut, repeat. Transforms return audio handles |
video.* |
info, trim, resize, rotate, addAudio, extractAudio, extractFrame. Transforms return video, audio, or image handles as appropriate |
media.* |
bytes, text, info read a document/image/audio/video input; toDocument, toImage, toAudio, toVideo build one to emit or output. Needs a ProcessingContext |
canvas.measureText / createCanvas(w, h) |
Canvas 2D drawing, recorded in the guest and replayed on a real host context by await surface.toBytes() |
assetToSandbox(assetId, path) / sandboxToAsset(path) |
Move an asset in and out of the workspace |
progress(percent, message?) |
Fire-and-forget progress, rate-limited and capped |
emit(name, value) / output(name, value) |
The Code node's output contract: stream a value now, or record a handle's final value. Awaitable; emit awaits drain under backpressure |
toBase64 / fromBase64 / toHex / fromHex / parallelMap |
Pure guest helpers, no host call behind them |
Core JavaScript — JSON, Math, Date, Map, Set, RegExp, URL,
URLSearchParams, TextEncoder/TextDecoder — is QuickJS's own, not a
host-bridged version.
Callers add their own globals through RunSandboxOptions.globals:
inputs and state for the Code node, tools/finish/__graphQueues for
CodeAct, node/graph for the graph DSL. Names in RESERVED_SANDBOX_NAMES
cannot be overwritten this way.
A document, image, audio or video input arrives in the guest as a ref
object, not as bytes. media is the bridge that resolves one, and the bridge
that turns bytes back into a ref the next node can read:
const bytes = await media.bytes(inputs.pdf);
const page = await media.text(inputs.notes); // utf-8 unless `encoding` says otherwise
const { mimeType, size } = await media.info(inputs.pdf);
await output("report", await media.toDocument(bytes, { mimeType, filename: "report.pdf" }));| Call | Returns |
|---|---|
media.bytes(ref) |
Uint8Array |
media.text(ref, { encoding? }) |
string |
media.info(ref) |
{ type, mimeType, uri, size } |
media.toDocument(bytes, { mimeType?, filename? }) |
DocumentRef |
media.toImage(bytes, { mimeType? }) |
ImageRef |
media.toAudio(bytes, { mimeType? }) |
AudioRef |
media.toVideo(bytes, { mimeType? }) |
VideoRef |
Every call is async — await all seven.
A ref resolves from any of the forms the rest of NodeTool produces: asset://,
a storage path (/api/storage/<key>), an https: URL, a data: URI, a
package:// asset shipped inside a node package, and a plain file path. Pass
the whole input object, not its uri — media.bytes(inputs.pdf) — so the ref's
own type travels with it.
These are capabilities, not libraries: they need a ProcessingContext, so
they resolve inside a workflow run and throw in a bare runInSandbox call with
no context. For the libraries that read what the bytes contain — a PDF, a
spreadsheet, a zip — import a sandbox package.
image.*, audio.*, and video.* accept a media ref, encoded bytes, or a
handle from an earlier operation in the same run. A transform returns a small
sandbox://media/<id> handle with its media type. The encoded payload stays on
the host while calls
chain:
const joined = await audio.concat([inputs.intro, inputs.voiceover]);
const clip = await video.trim(inputs.video, { start: 2, end: 12 });
const finished = await video.addAudio(clip, joined, {
keepOriginalAudio: true
});
await output("video", await video.toAsset(finished));Use <type>.bytes(handle) only when code must inspect the encoded payload.
Use <type>.toAsset(handle) or media.toImage/toAudio/toVideo(handle) before
the run ends when the result must persist. Handles do not work in a later run.
Audio and video transforms use Mediabunny. Browsers use WebCodecs, and Node
uses Mediabunny's server codec adapter. The sandbox does not expose workflow
nodes or their packages.
There is no library global. Every library the sandbox offers is a sandbox package the run declares and imports:
import yaml from "@nodetool-ai/sandbox-yaml";
const config = yaml.load(inputs.text);
await output("config", config);NodeTool ships thirty-eight (packages/sandbox-packs/): -dates (date-fns),
-yaml (js-yaml), -markdown (marked), -qr (uqr), -color (culori),
-decimal (decimal.js), -jmespath (jmespath), -stats (simple-statistics),
-rrule (rrule), -gif (gifenc), -dsl (NodeTool's graph builder) and
-flow (NodeTool's node callables) run
inside the guest; -csv (papaparse), -html (cheerio + turndown), -xml
(fast-xml-parser), -xlsx (exceljs), -diff (diff), -zip (fflate), -ocr
(tesseract.js), -tfjs (TensorFlow.js and its model zoo), -docx (docx),
-mammoth (mammoth), -epub (epub2), -fabric (Fabric.js — SVG and vector
scenes), -pdflib (pdf-lib), -pptxgen (PptxGenJS), -chrono (chrono-node),
-exif (exifr), -expr (expr-eval), -ics (ics), -subtitle (subtitle),
-tokens (js-tiktoken), -pptx (office-text-extractor) and -pdf
(pdf-parse) run on the host behind a
generated facade, because they need Node builtins or a DOM, or carry a limit
the guest could not enforce on itself (zip's 50 MB inflation cap), or hold
state no run can keep alive (the tfjs weights). Four more carry
NodeTool's own code rather than a library — -aws signs a request with SigV4,
and -notion, -supabase and -twilio build an authenticated one —
and none of them sends it:
the guest passes what comes back to its own fetch, under the run's fetch cap
and SSRF guard. Every shipped pack is available out of the box — a checkout, the
desktop app and the server image each read them from where their own build put
them. A third-party pack is installed through the Package Manager and is
discovered the same way. See
Sandbox packages and
packages/sandbox-packs/README.md.
Every default below is overridable per invocation through
RunSandboxOptions.limits and clamped to the ceiling in the last column.
| Limit | Default | Enforced by | Ceiling |
|---|---|---|---|
| Execution time | 30 s (timeoutMs) |
interrupt handler on a CPU budget + wall-clock race | — |
| CodeAct action timeout | 600 s | DEFAULT_CODEACT_ACTION_TIMEOUT_MS |
— |
| Suspended time | 30 min | suspendAllowanceMs, only with a clock |
— |
| Guest heap | 64 MB | runtime.setMemoryLimit |
512 MB |
| Call stack | 512 KB | runtime.setMaxStackSize |
8 MB |
| Fetch calls | 20 per run | counter in the bridge | 100 |
| Fetch body | 1 MB | truncation in the bridge | 50 MB |
| Fetch timeout | 15 s | per-request AbortController |
120 s |
| Fetch redirects | 5 hops | the bridge | — |
| Output size | 100 KB | serializeResult |
10 MB |
| Random bytes | 64 KB per call | crypto.getRandomValues clamp |
— |
| Progress reports | 1000 per run, one per 100 ms | counter + timestamp | — |
| Host module text input | 5 MB | host-modules/limits.ts |
— |
| Host module byte input | 10 MB | host-modules/limits.ts |
— |
| Image input | 25 MB, 32 M pixels, 16384 px longest edge | assertSurfaceSize |
— |
| Run media handles | 256 MB total encoded payload | SandboxMediaStore |
— |
| Canvas ops | 10 000 per render | renderCanvas |
— |
| Tool calls per action | 50 | DEFAULT_MAX_TOOL_CALLS_PER_ACTION |
— |
QuickJS's memory limiter counts its own heap objects. String and typed-array
payloads are not charged against it, so memoryLimitBytes bites on object
allocation, not on new Uint8Array(n).
The sandbox is fully asynchronous, and a bridge call starts its host-side work
when it is invoked, not when it is awaited. Promise.all over five fetches
therefore takes one round trip, not five:
const pages = await Promise.all(urls.map((u) => fetch(u)));parallelMap(items, fn, concurrency = 5) is the bounded form — order-preserving,
maximum concurrency 32, rejecting on the first failure. Parallel calls count
against the per-run fetch cap exactly like serial ones.
Timer globals (setTimeout, setInterval, setImmediate and their clears) are
deleted inside the user-code module. Their callbacks would fire through
ctx.callFunction with errors discarded, outside the never-reject and
abort-guard conventions every bridge follows. sleep is the only timer.
Anything crossing the WASM boundary follows four rules. Break one and the symptom is silent data corruption, not an error.
- Host async functions never reject. A failing bridge resolves a tagged
{__nodetool_sandbox_error__: true, name, message}object, and a guest prelude re-throws it as a realError. This works around a handle leak in@sebastianwessel/quickjs@3.0.1that trips an assertion (list_empty(&rt->gc_obj_list)) when the runtime is freed. - Binary crosses asymmetrically. Guest → host is native: typed-array
serializers registered with
addSerializerturn a guestUint8Arrayinto a host one. Host → guest is not: a returnedUint8Arraywould arrive as a numeric-keyed plain object, so byte-producing bridges return a base64 marker object that the guest prelude rebuilds. Follow this for any new binary bridge. - Results are scanned at any depth.
serializeResultwalks for typed arrays through the whole value; the streaming path always nests bytes two levels down, and aUint8Arraythat falls toJSON.stringifybecomes{"0":137,"1":80}— lossy and indistinguishable from a user's integer-keyed map. - Object globals sync back. After the guest runs, object-typed globals are deep-replaced on the host. Primitives pass by value and do not sync.
- Structured data crosses as JSON, not as handles. The wrapper marshals an
object node by node — four
evalCodecompilations plus a property-by-property descriptor copy each — so a run handed 5 000 rows spent ~2 s reading them and ~0.8 s handing them back.sandbox-json-transport.tsmoves one string instead: the guest builds its result withJSON.stringifyand the host parses it, the host encodes injected globals for the prelude to parse, andemit/outputarguments take the same path. Typed arrays and strings over 8 KB ride a sidecar the marshaler moves whole; dates, non-finite numbers andundefinedproperties carry markers, because JSON alone would flatten or drop them. A value the encoder cannot represent — a function, a bigint, aMap, a class instance — falls back to the wrapper's own marshaling. A cycle is refused outright: the wrapper follows it until the runtime aborts, so a cyclic global fails the run by name and a cyclic result comes back asString(value).
The guest starts with less than plain QuickJS, and every capability past that is one the host granted.
- No dynamic code generation.
evalandFunctionare deleted before any user code evaluates. - No ambient modules. A body that imports nothing gets no loader at all.
With one, only the packs the run resolved and their intra-pack siblings
resolve; dynamic
import()is always denied. Enforcement sits in the normalizer, not the loader, because QuickJS serves an already-cached module without consulting the loader — that is what keepsnode:bufferand the rest of the wrapper's compat preamble out of reach after bootstrap. - Scoped secrets. A run may declare the secret names it needs
(
limits.secretScope, host-set). ThegetSecretbridge refuses every other name, so a node that talks to one service cannot read another's credentials — and because the check is at the bridge,nodetool.secrets.getcannot route around it. An absent scope is unscoped, which is what a Code node authored before scopes existed still gets; an empty declared scope denies everything. Writing one is a separate question with a stricter answer: guest code cannot set a secret at all. Thesettingscapability module (@nodetool-ai/sandbox-nodetool/settings) reads and writes ordinary configuration, refusesget_setting/set_settingon anything the catalog marks a credential, and offersrequest_secret— which takes a name and a reason, never a value. It opens a dialog in the user's client, the user types the key there, and the client saves it with its own authenticated call, so the credential never passes through the guest, the websocket frame, or the model's context. A run with no interactive client is refused by name rather than falling back to a write nobody saw. - SSRF guard.
fetchrefuses loopback, link-local and private ranges, including IPv6 forms and IPv4-mapped addresses, and re-checks on every redirect.limits.allowPrivateNetworklifts it; it is host-set only, so guest code cannot enable it for itself. - Workspace containment.
workspace.*resolves inside the workspace root and re-checks the symlink-resolved real path immediately before each operation.limits.filesystemAccess: "host"lifts that to the whole filesystem the process can reach. Both switches exist becauselib.httpandlib.osnodes always had that reach and a Code node replacing one must match it; both default to the restrictive value, and the graph migration sets the filesystem switch only on nodes rewritten from such a node. - Cancellation. Once the abort signal fires, every subsequent bridge call
fails fast and the guest unwinds. A purely CPU-bound loop still runs to its
execution timeout — QuickJS's wrapper exposes no interrupt input — but
runInSandboxreturns as soon as the signal fires. - The platform API is scoped to the caller's own rows. Every capability
that reads or writes NodeTool's own data resolves the row against the run's
user id, and missing and not-yours are one answer so a run cannot probe for
ids. What is deliberately not reachable is written down per procedure in
packages/websocket/src/trpc/sandbox-coverage.tsand checked against the live router: credentials, billing, other tenants, host control, the transcript of the run's own behaviour, and anything that grants a third party access. Publishing is the one exception, and it asks —set_workflow_accessis classifiedexternal, so the permission gate prompts before a workflow becomes readable outside the account. - The gate is the host's, not the sandbox's. A Code node and a JS script
read
PERMISSION_GATE_CONTEXT_KEYoff the run's context, so a body reached from a chat in plan mode is refused a write capability through both doors — the@nodetool-ai/sandbox-nodetool/*import and the belt bridge. A kernel workflow run sets no gate and runsauto, since pressing Run on the graph was the consent. - Bridges are the boundary, not the hiding. The host and WASM module dispatchers validate module identity, export name and argument list before any implementation loads, and their bindings are deleted before user code starts. A pack module that captures a binding during linking gains nothing beyond the run's own declared surface.
Known accepted risk: the realpath check and the filesystem call after it are
separate awaits, so an in-workspace symlink swapped between them is a TOCTOU
window. Closing it needs fd-based operations (O_NOFOLLOW/openat) that
node:fs/promises does not expose. It requires a local attacker racing inside
the workspace, on surfaces that run first-party or already-trusted code.
A pack declares its sandbox modules in the nodetool field of its
package.json. Three kinds, one import surface:
| Kind | Runs | Declared as |
|---|---|---|
| Guest JS | inside QuickJS | authored source, or {"npm": "<dependency>"} compiled by packages/sandbox-compiler |
| Host JS | where the sandbox runs | {"kind": "host", "host": "<id>"}, resolved only through SANDBOX_HOST_MODULES in @nodetool-ai/protocol |
| WASM | host worker pool | manifest exports with scalar-only signatures, behind a generated facade |
A host id resolves only if the registry pins that exact package as the one allowed to declare it, so a third-party pack can never bring host code. WASM calls are stateless by contract: each instantiates fresh from the cached module, runs, and is discarded.
Compilation of an npm-declared module is cached by content digest, never by version:
npm run dev:nodetool -- packs compile # every installed pack
npm run dev:nodetool -- packs compile --force # recompile and re-probeAnything that stops a module short of admission is a named skip, not an error:
npm-module-builtin-import, npm-module-unresolved, npm-module-too-large
(1 MB), npm-module-forbidden-global, npm-module-probe-failed.
The browser runner fetches module sources over
GET /api/sandbox-modules/* by opaque module id, and the catalog authorizes and
retrieves in one call — the route never touches the filesystem. Bodies are
verified before they run, so the loading and denial contract is the same in the
page as on the server.
nodetool.code.Code is the sandbox as a workflow node.
// inputs: { rows: [...], threshold: 10 }
const kept = inputs.rows.filter((r) => r.score > inputs.threshold);
progress(50, `kept ${kept.length}`);
for (const row of kept) await emit("row", row); // streams as it goes
await output("count", kept.length); // final, posted at the end
// outputs: row, count- Inputs arrive on the
inputsobject, never as globals of their own name. Sharing the global namespace let an input calledenvshadow a bridge and made every undeclared identifier ambiguous between a typo and a missing slot. Values are deep-copied through JSON before entering the guest. - Outputs leave the body through two awaitable calls, and nothing else.
await output(name, value)records a handle's final value; all final values post together as one bag when the body completes, and a secondoutputon the same handle throws.namemust be a declared output handle.returnis ordinary control flow — its value is ignored. - Media inputs arrive as refs. Read one with
media.bytes/media.text, and output one built bymedia.toDocument/toImage/toAudio/toVideo. - Streaming.
await emit(name, value)delivers{[name]: value}downstream immediately, while the body keeps running — call it any number of times per handle. Awaiting it applies backpressure once the queue is full. A body that emits nothing simply never streams; there is no separate streaming mode to opt into. - Legacy bodies. A body that calls neither
emitnoroutputruns the old return/yield contract — returned object keys as outputs, implicit return of the last expression,yieldreplayed after the run — for one more release, with a deprecation warning fromvalidate_codeand from the editor. See code-node-emit-design.md for the contract and the migration. stateis a plain object that survives across streaming invocations and resets at the start of each workflow run.progress(percent, message)postsnode_progressto the kernel — the same channel the Python worker uses — so a long snippet drives the node's progress bar.
stream is the input side of emit: four verbs that read the node's connected
handles as values arrive, instead of once per item.
for await (const item of stream(name)) // one handle, in order, until EOS
for await (const [handle, item] of stream.any()) // every handle, arrival order
const item = await stream.first(name) // next value, undefined at EOS
stream.open(name) // could more still arrive?stream(name) and stream.any() are async iterables that complete at
end-of-stream; stream.first is for a value that arrives once; stream.open is
synchronous and consumes nothing. Values are marshaled exactly like buffered
inputs — JSON deep copy, media as refs readable through media.*.
// Running total, live, with a summary at the end.
let sum = 0;
for await (const n of stream("numbers")) {
sum += n;
await emit("running", sum);
}
await output("total", sum);The body decides the mode. A body that mentions stream runs once for
the whole stream and pulls its own items; a body that never mentions it keeps
today's contract — one invocation per incoming item, with inputs holding that
item's snapshot. Nothing is configured: hydration re-reads the body each time
(usesStreamInputContract), so deleting the last stream call flips the node
back.
The split has three consequences worth knowing:
inputs.<name>in a streaming body carries the node's configured property values, never per-item edge data. A connected handle is reachable only throughstream(name); reading one viainputs.is a validation error naming the call to use.- Outputs leave through
emit/outputonly. There is one invocation, so its return value is control flow.outputfinals post as one bag when the body ends; a body that throws mid-stream keeps what it already emitted and drops the finals. statestill exists and is pointless here. One invocation means plain local variables already survive across items.
Waiting is not executing: time parked on a take is clock-suspended, so
timeout meters the body's own work and a slow upstream cannot kill a correct
consumer. What bounds the run is cancellation — a cancelled run unparks every
take as end-of-stream and the body unwinds. Backpressure is free in the other
direction too: the guest pulls, so an item the body has not asked for stays in
the kernel's inbox.
run_code and test_code take the same bodies: stage items with
input_streams: {handle: [...]} and the harness answers takes from them,
interleaving stream.any() round-robin by index across the handles you
declared. Design:
code-node-input-streaming-design.md.
Node props map onto sandbox policy: timeout (seconds, 0 for none),
max_response_mb, allow_local_network → limits.allowPrivateNetwork,
allow_host_filesystem → limits.filesystemAccess, secrets →
limits.secretScope. Modules come from the body itself: the packs its static
imports name are resolved against the installed catalog. A pack no install
serves fails the node before the guest starts rather than surfacing as a
resolve error inside it; version or digest drift only warns on the node's log.
On a server host the node's code also gets the nodetool object model, backed
by the agent toolbelt. A JS script run uses that same belt, and one function
assembles it for both (assembleSandboxToolbelt). The belt is loaded
lazily and only on Node, since the in-browser runner bundles this module:
without one, nodetool.capabilities() reports {} and each method throws
naming the tool it needs instead of a ReferenceError. run_code stays
hermetic so an authoring probe does not get the belt.
It is not the chat belt: ui_* needs a browser, and a session can add tools of
its own. What it does carry is everything a chat action can reach on the
server, Apify and SerpAPI included — a call a chat made and a node then repeats
resolves to the same tool.
Static checking. nodetool validate parses each Code node body and reports
what a run would hit: invalid JavaScript, top-level export, an import no
installed pack serves, a bare read of a name that is neither a
sandbox API nor one of the node's own inputs (they live on inputs, so a bare
read is a ReferenceError), an inputs.<name> the node does not declare, an
emit/output call naming a handle the node does not declare, and a declared
handle no reachable call ever writes. The analysis lives
in @nodetool-ai/node-sdk (code-analysis.ts, code-node-validation.ts), so
the validator, the submit_code planner and the editor read one AST.
Every agent step acts by writing a program, not by emitting one JSON tool call.
The model sees a single provider tool, execute_code({code}); the program runs
in the sandbox with the step's toolbelt exposed as async functions, and one
round trip can chain, loop over, branch on and reduce any number of tool calls.
Design and the research behind it: CodeAct design.
What an action gets on top of the standard surface:
-
import { <name> } from "@nodetool-ai/sandbox-nodetool/<namespace>"— the belt, reached the way a library is. There is notools.<name>()global: a name a capability module owns comes from that namespace, a clientui_*tool from.../ui, and anything the session added at its own call site from.../session. One import shape covers all three, and a body's imports say what it uses before it runs. A tool returning an{error}payload throws in the guest, sotry/catchis the idiom. Each invocation surfaces to the host as atool_call_update(idcodeact_<n>), so composition inside one action stays observable.toolsstill exists, as a thrower. Every property access reports the import that replaces it — read off the__toolModulesmap the host installs — so a body written against the old global saysimport { web_search } from "@nodetool-ai/sandbox-nodetool/web";rather thanReferenceError. -
stateis gone from CodeAct. It used to persist across the actions of a step (and, on the chat runner, across a thread via an LRU), which made cross-turn carry process-local and silently lossy. Durable carry is thread memory now: generation results are already assets (asset://refs), and an action records what later actions or turns need withnodetool.memory.save. Handles fromimage.*/audio.*/video.*die between actions; convert withnodetool.media.toImage/toAudio/toVideofirst. -
finish(result)— completes the step. For schema'd steps the host validates, and an invalid result throws in the guest with the violation list, so the same action can repair. -
nodetool.searchTools(query)— in-sandbox discovery for tools the prompt lists by name only, past the disclosure threshold. Each hit carriesname,signature,description, and themodule/specifier/importline that makes it callable, so discovery answers "where do I import this from" in the same round trip as "what are its arguments". Deferred tools stay callable; the split spends prompt tokens, not capability. -
nodetool.packs— what this action may import:list()reports every installed pack and whether the session allows it,modules(pack)the specifiers it declares,exports(specifier)the function names one module exports, anddocs(specifier)the pack's SKILL.md. -
nodetool.*— the platform as objects (workflows,graph(),nodes,agents,models,media,assets,jobs,collections,web,memory, and the rest), each method wrapping a belt tool. A method whose backing tool is absent throws naming it.nodetool.media.understandVideo(video, prompt, model)is the video half of the judging methods: it hands a whole clip to a model that reads video (Gemini) and answerspromptas{text}. -
nodetool.media.ffmpeg/ffprobe/downloadVideo— the host binaries.ffprobe(path)reads a file's format and streams;ffmpeg(args)takes argv, and that argv is bounded on the way out: every path is resolved against the workspace and refused when it lands outside (symlinks resolved), inputs may open local files only (-protocol_whitelist file,crypto,databefore every-i, plus a token scan for://,concat:,pipe:,/dev/…), and the run is bounded on wall clock, captured output, artifact size, and how many host binaries may run at once. Fetch what you need withdownloadVideoorfetch, then pass the local path.downloadVideocarries its own boundary: the URL must be public (loopback, link-local and the private ranges are refused, by literal and by DNS answer), the download is capped at 2 GiB, and the run ignores config files — yt-dlp readsyt-dlp.conffrom its working directory, which is the workspace guest code writes, and that file can ask for--exec. -
openWorkflow(id)— when the belt carries theui_*document tools, a graph object model whose synchronous mutators queue operations against a local mirror, replayed through the same tool contract byawait wf.commit().
The action inherits exactly the privileges tool mode already granted: every
imported belt function is a tool the model could have called directly, and
per-step allowlists stay a privilege boundary. What is genuinely new is composition —
one action can chain calls without per-call visibility in the provider
transcript — which the per-action tool-call cap, the tool_call_update events
and the action timeout bound.
A Code node runs a body a person saved; an action is code the model just
wrote. So an action imports only what the session allowed
(sandboxPackages), and the prompt advertises only those specifiers, one
sanitized line each, never the installed catalog. A session that allowed nothing
imports nothing, and an off-allowlist import stops the action before the guest
starts — the model sees the refusal as its observation and can correct it. A
session with packages also carries get_sandbox_package_docs, which serves one
pack's SKILL.md and wraps an untrusted pack's body in
<untrusted-package-docs>: reference, never instructions.
A chat action that calls a gated tool parks on a person's answer. Charged to the
same budget, that wait kills the program that asked, and answering then resolves
nothing. createSandboxClock gives the caller clock.suspend(): suspended time
is added back to timeoutMs, suspensions nest, and the engine's own abort moves
out to timeoutMs + suspendAllowanceMs as the backstop for a prompt nobody
answers. The interrupt handler still cuts a runaway loop at exactly timeoutMs
of running time. The websocket chat runner owns one clock per turn and suspends
it around every tool- and plan-approval round trip.
validate_workflowevaluates a legacy graph DSL program (itscodeparameter) in the sandbox with no host access — onlynode()andgraph()— so a malformed or hostile program cannot reach anything.- The plain
jstool is gone.execute_codeis the one code path an agent acts through; it declares the session's sandbox packages.run_coderemains as a hermetic authoring harness — it executes a Code-node body with no toolbelt and only the secrets the call names — and library-backed work outside an agent goes through a Code node that imports the pack.
| Symptom | Cause |
|---|---|
url.searchParams.set() does not affect the parent URL |
QuickJS URL limitation. Build the query with URLSearchParams directly |
A Uint8Array arrives as {"0":137,…} |
A host → guest byte path that skipped the base64 marker convention |
An import resolves nothing |
The run declared no modules, or the specifier is off the allowlist |
setTimeout is not defined |
Deleted deliberately. Use sleep, Promise.all or parallelMap |
A bare identifier is a ReferenceError in a Code node |
Node inputs live on inputs, not in the global scope |
nodetool.* throws naming a tool |
The host has no toolbelt (browser runner, no context) or that tool is not on the belt |
tools.<name> is gone |
The flat toolbelt was retired for imports. The message names the import that replaces the call, or points at nodetool.searchTools(name) when no module owns the name |
Could not find export '<name>' in module '…/<namespace>' |
The namespace is mounted but this host's belt has no such tool. nodetool.searchTools(name) lists what it does have; ui_* is browser-only and never on a Code node's belt |
A Code node has an empty nodetool.capabilities() and cannot import @nodetool-ai/sandbox-nodetool/* |
The host never called setCodeNodeAgentsModule. The node resolves @nodetool-ai/agents by bare specifier, which resolves in a checkout and nowhere in the bundled backend (esbuild inlines the workspace packages into server.mjs); packages/websocket/src/server.ts hands over the inlined copy at bootstrap |
| A CPU-bound loop outlives its cancellation | The signal ends runInSandbox, but the guest loop still runs to the execution timeout |
- A new bridge: add it in
buildSandbox, wrap async work inneverReject+guardAbort, return bytes as a base64 marker object, add the name toEXPOSED_BRIDGE_NAMES, and describe it inpackages/agents/src/code-gen/sandbox-manifest.ts. The manifest reads limits and names out ofjs-sandbox.tsrather than restating them, so a prompt derived from it cannot advertise an API the sandbox does not marshal —tests/sandbox-manifest-drift.test.tsholds that line. - A new library: ship a sandbox pack. Guest-side if it compiles under the
admission probe, host-side if it needs Node builtins, a DOM, or a limit the
guest could not enforce. Host implementations live in
packages/agents/src/host-modules/with every safety limit inside them, where nothing can route around them.
Tests: packages/agents/tests/js-sandbox.test.ts (surface, limits, async
concurrency), js-sandbox-modules.test.ts, js-sandbox-wasm.test.ts,
host-modules.test.ts, codeact-executor.test.ts, chat-codeact.test.ts,
nodetool-api*.test.ts, and packages/sandbox-compiler/tests/packs.test.ts for
every shipped pack through the real install path.
- CodeAct design — the action protocol and its research
- Sandbox packages — the pack system, trust model, milestones
- Chat & Agents — the agent surfaces whose actions run here
- Execution strategies — where sandboxed work sits among the run modes