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, a planner’s graph program and the run_code tool 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.

Where it runs

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
Script mode packages/agents/src/script-runner.ts An LLM-authored orchestration script whose agent() calls spawn sub-agents
GraphPlanner submit_graph packages/agents/src/graph-dsl.ts A graph DSL program with no host access at all
run_code tool packages/agents/src/tools/code-tools.ts A one-shot snippet, no packages declared
js tool (MiniJSAgentTool) packages/agents/src/tools/js-code-tool.ts Same, with the bridge surface documented in the tool description
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.

Anatomy of a run

  1. Resolve limits. resolveSandboxLimits applies 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.
  2. Build the bridges. buildSandbox constructs the host-side objects (fetch, workspace, crypto, …) bound to this run’s context, limits and abort signal. Each async bridge is wrapped in neverReject and guardAbort.
  3. Build the entry module. buildEntryModule parses the code with acorn, hoists static import declarations above the wrapper, and emits the rest as the body of a top-level-awaited async IIFE, so return value becomes the module’s default export. Code acorn cannot parse falls through to wrapCode unchanged, so a syntax error reaches the user as they wrote it.
  4. Install the module loader — only when the run declares modules. Without modules, no loader exists and every import resolves nothing.
  5. Init prelude. eval, Function and the wrapper’s unconditional stubs (Buffer, process, env, Headers, Request, Response, performance) are deleted. The entry module additionally deletes the timer globals, which the wrapper library re-installs on every evaluation.
  6. Run, under an interrupt handler on a CPU deadline and a wall-clock race.
  7. Serialize. serializeResult walks the returned value (cycle-safe, depth-capped at 32) converting typed arrays at any depth, then truncates to maxOutputSize. Object-typed globals are deep-replaced on the host, which is how the Code node’s state survives between invocations.

The guest surface

Two kinds of thing, and the difference matters: capabilities are globals, libraries are imports.

Capabilities (globals)

Global What it does
console.* Log lines land in the run’s logs array
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
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, decode, encode, resize, crop, rotate, flip, adjust, composite, convert over encoded bytes
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
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/state/finish for CodeAct, node/graph for the graph DSL. Names in RESERVED_SANDBOX_NAMES cannot be overwritten this way.

Libraries (imports)

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);
return { config };

NodeTool ships eight (packages/sandbox-packs/): -dates (date-fns) and -yaml (js-yaml) run inside the guest; -csv (papaparse), -html (cheerio + turndown), -xml (fast-xml-parser), -xlsx (exceljs), -diff (diff) and -zip (fflate) 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). Third-party packs install the same way. See Sandbox packages and packages/sandbox-packs/README.md.

Limits

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
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).

Concurrency

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.

Marshaling rules

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 real Error. This works around a handle leak in @sebastianwessel/quickjs@3.0.1 that 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 addSerializer turn a guest Uint8Array into a host one. Host → guest is not: a returned Uint8Array would 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. serializeResult walks for typed arrays through the whole value; the streaming path always nests bytes two levels down, and a Uint8Array that falls to JSON.stringify becomes {"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.

Security model

The guest starts with less than plain QuickJS, and every capability past that is one the host granted.

  • No dynamic code generation. eval and Function are deleted before any user code evaluates.
  • No ambient modules. Without a declared resolution there is no loader at all. With one, only the run’s declared packages 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 keeps node:buffer and the rest of the wrapper’s compat preamble out of reach after bootstrap.
  • SSRF guard. fetch refuses loopback, link-local and private ranges, including IPv6 forms and IPv4-mapped addresses, and re-checks on every redirect. limits.allowPrivateNetwork lifts 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 because lib.http and lib.os nodes 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 runInSandbox returns as soon as the signal fires.
  • 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.

The module system

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-probe

Anything 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.

The Code node

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}`);
return { kept, count: kept.length };
// outputs: kept, count
  • Inputs arrive on the inputs object, never as globals of their own name. Sharing the global namespace let an input called env shadow 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 are the returned object’s keys. Return a non-object and it becomes a single output handle. Code with no return gets an implicit return of its last expression.
  • Streaming. Code containing yield runs through genProcess: the yields are collected in the guest and emitted one message at a time.
  • state is a plain object that survives across streaming invocations and resets at the start of each workflow run.
  • progress(percent, message) posts node_progress to the kernel — the same channel the Python worker uses — so a long snippet drives the node’s progress bar.

Node props map onto sandbox policy: timeout (seconds, 0 for none), max_response_mb, allow_local_networklimits.allowPrivateNetwork, allow_host_filesystemlimits.filesystemAccess, and packages → the declared module resolution. An undeclared or unserveable package 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. 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.

Static checking. nodetool validate parses each Code node body and reports what a run would hit: invalid JavaScript, top-level export, an import the node’s packages does not declare, 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, no return, or a declared output left unset on some return path. 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.

Agents

CodeAct: code as the action space

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:

  • tools.<name>(args) — one wrapper per tool on the belt, generated by TOOLS_PRELUDE over a single __callTool bridge. A tool returning an {error} payload throws in the guest, so try/catch is the idiom. Each invocation surfaces to the host as a tool_call_update (id codeact_<n>), so composition inside one action stays observable.
  • state — persists across the actions of a step, host-side, synced back after every run.
  • 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.
  • searchTools(query) — in-sandbox discovery for tools the prompt lists by name only, past the disclosure threshold. Deferred tools stay callable; the split spends prompt tokens, not capability.
  • 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.
  • openWorkflow(id) — when the belt carries the ui_* document tools, a graph object model whose synchronous mutators queue operations against a local mirror, replayed through the same tool contract by await wf.commit().

The action inherits exactly the privileges tool mode already granted: every tools.* 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 carries packages 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.

The suspending clock

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.

Other agent surfaces

  • Script mode (ScriptRunner) executes an LLM-authored orchestration script in the sandbox, where agent(), parallel(), pipeline(), log() and budget bridge to real sub-agents on the host.
  • GraphPlanner runs each submit_graph program in the sandbox with no host access — only node() and graph() — so a malformed or hostile program cannot reach anything.
  • run_code and js are the plain code tools. Neither declares packages, so nothing is importable in them; library-backed work goes through a Code node with the package declared.

Failure modes

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
A CPU-bound loop outlives its cancellation The signal ends runInSandbox, but the guest loop still runs to the execution timeout

Extending it

  • A new bridge: add it in buildSandbox, wrap async work in neverReject + guardAbort, return bytes as a base64 marker object, add the name to EXPOSED_BRIDGE_NAMES, and describe it in packages/agents/src/code-gen/sandbox-manifest.ts. The manifest reads limits and names out of js-sandbox.ts rather than restating them, so a prompt derived from it cannot advertise an API the sandbox does not marshal — tests/sandbox-manifest-drift.test.ts holds 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.