NodeTool Cloud is in alpha. Try it โ†’

The JavaScript sandbox

One execution environment. Write it, or let an agent write it.

Write a custom Code node, save a reusable script, or let an agent generate the logic. It all runs in the same QuickJS WebAssembly isolate โ€” same engine, same limits, same imports.

Capabilities are globals

Host-granted permissions โ€” fetch, workspace, secrets, media โ€” are injected as globals for that run.

Libraries are imports

38 built-in utility packs, reached with a standard ES import. No library global, no second route.

Nodes are functions

Call any of 424 AI nodes as a standard async function. await is the edge, a variable is the wire.

Anatomy of a run

A stripped-down guest, and what the host hands it

Your code starts with less than plain QuickJS. Everything past that is a bridge the host built for this specific run, bound to its limits and its abort signal. Nothing in the guest can widen a grant it was given.

A body that streams

One import for the library, one global for the capability, two awaitable calls for the outputs.

// A Code node body. Inputs arrive on `inputs`, never as globals.
import { parse } from "@nodetool-ai/sandbox-csv";

const rows = parse(await media.text(inputs.file)).data;
const kept = rows.filter((r) => Number(r.score) > inputs.threshold);

for (const row of kept) {
  await emit("row", row);          // streams downstream now
}
progress(100, `kept ${kept.length}`);
await output("count", kept.length); // final, posted when the body ends

The body picks the mode. Mention stream(name) and it runs once for the whole stream, pulling its own items. Leave it out and it runs once per incoming item. There is nothing to configure.

The granted surface

  • fetch

    HTTP with the SSRF guard in front of it. 20 calls, 1 MB bodies, 15 s each, every redirect re-checked.

  • workspace

    read, write, list, stat, copy, move, mkdir, remove. Jailed to the run's workspace root, symlinks resolved before every call.

  • nodetool.secrets

    get, tryGet, list โ€” narrowed to the names the node declared. There is no setter anywhere in the guest.

  • image / audio / video

    Decode, trim, resize, mix, composite. Transforms return handles, so the bytes stay on the host while you chain calls.

  • emit / output / stream

    The node's IO contract. emit streams a value now under backpressure, output records a final, stream() pulls items as they arrive.

  • media / canvas / crypto / format

    Resolve a ref to bytes, draw on a 2D surface replayed on a real host context, hash with WebCrypto, format with the host's Intl.

Gone before your code loads

The prelude runs first, so there is no window where these exist.

eval and Function

Deleted before a single line of user code evaluates.

setTimeout and friends

Their callbacks would fire outside the run's error contract. sleep(ms) is the only timer.

Ambient modules

A body that imports nothing gets no loader at all. Dynamic import() is denied outright.

Buffer, process, env

The Node-compat preamble never installs them, and the prelude deletes them anyway.

Sandbox packs

38 built-in sandbox packs

Bodies declare what they need by importing it. Every import is resolved against the installed catalog before the guest starts, so importing something we do not serve fails the node immediately โ€” not halfway through a run you already paid for.

import yaml from "@nodetool-ai/sandbox-yaml";
import { sigv4 } from "@nodetool-ai/sandbox-aws";

// -aws signs the request. It never sends one: the guest passes the
// signed headers to its own fetch, so the run's cap and its SSRF
// guard still apply.
const signed = await sigv4({
  method: "GET",
  url: `https://${inputs.bucket}.s3.us-east-1.amazonaws.com/${inputs.key}`,
  region: "us-east-1",
  service: "s3",
  accessKeyId: await nodetool.secrets.get("AWS_ACCESS_KEY_ID"),
  secretAccessKey: await nodetool.secrets.get("AWS_SECRET_ACCESS_KEY")
});

const res = await fetch(signed.url, {
  method: signed.method,
  headers: signed.headers
});
await output("config", yaml.load(await res.text()));

Guest-compiled

Lightweight libraries bundled straight into the isolate by esbuild, then scanned and probed before admission

datesyamlmarkdownqrcolordecimaljmespathstatsrrulegifdslflow

Host-bridged

Heavy workloads, Node builtins, a DOM, or a limit the guest could not enforce on itself โ€” reached through a generated facade

csvhtmlxmlxlsxdiffzipocrtfjsdocxmammothepubfabricpdflibpptxgenpptxpdfchronoexifexpricssubtitletokensawsnotionsupabasetwilio

A pack is two config files

A package.json manifest and a SKILL.md. No shipped pack writes a line of code โ€” the compiler bundles the npm dependency into the guest and caches it by content digest, never by version.

Host-side when the guest cannot hold it

zip runs on the host because an inflation cap enforced inside the guest is enforced by code the guest can decline to call. tfjs runs there because model weights outlive a run and outsize the 64 MB heap.

A pack cannot bring host code

kind: "host" carries an id, never an implementation, and that id resolves only if a first-party table pins the exact package to it. The per-run dispatcher re-checks on every call.

Flow vs. DSL

Pick your paradigm

Every node NodeTool ships is available as an import. We expose them two ways, and which one you want depends on what you need at the end: the values, or the graph that produced them.

@nodetool-ai/sandbox-flow

Use this when you just want the data.

Functions are direct, awaitable API calls. You handle concurrency with Promise.all and branching with if/else. Nothing is scheduled: the call resolves the node from the registry, injects secrets, runs process(), and returns the outputs โ€” the same execution the kernel performs, minus the actor.

424

node callables

68

namespace modules

0

graphs built

  • if, for and try are themselves. The pack ships no control-flow combinators.
  • Each callable is generated from the node's own metadata, so its inputs are the node's inputs.
  • Recursion is capped at depth 4, with 16 concurrently open streams per run.
import "@nodetool-ai/sandbox-nodetool/flow";
import { agent } from "@nodetool-ai/sandbox-flow/nodetool.agents";
import { textToImage } from "@nodetool-ai/sandbox-flow/lib.image";

// Promise.all is the fan-out. No ForEach node, no scheduler.
const shots = await Promise.all(
  inputs.beats.map((beat) =>
    textToImage({ prompt: beat, model: inputs.model })
  )
);

// A streaming node carries .stream. Breaking early closes it,
// and the node stops.
let notes = "";
for await (const chunk of agent.stream({ objective: inputs.brief })) {
  notes += chunk.chunk ?? "";
  if (notes.length > 2000) break;
}

await output("shots", shots.map((s) => s.output));
await output("notes", notes);

Which one to reach for

sandbox-flow

You want values. Branching, retries and concurrency are plain JavaScript, and every call still opens a node.process span and bills through the run's own context.

sandbox-dsl

You want an artifact โ€” something to open in the editor, validate, supervise, or hand to the server. The graph is just data until something checks it, so validate before you save.

WorkflowRunner

An actor per node, correlated lineage, end-of-stream propagation. Right for the editor and for supervised runs, pure overhead when the caller is code that just wants a value.

CodeAct

Agents write code, not tool calls

Standard tool-calling is a round trip per call: emit JSON, wait, loop. With CodeAct the agent writes a whole JavaScript program instead, and it runs in the same isolate your Code node runs in. It loops, branches and reduces locally, reaching 208 platform tools as imports across 33 namespaces. Its return value, logs and thrown errors are the observation for the next turn.

import { find_model } from "@nodetool-ai/sandbox-nodetool/models";
import { run_workflow } from "@nodetool-ai/sandbox-nodetool/workflows";
import { thread_memory_save } from "@nodetool-ai/sandbox-nodetool/memory";

// One action. Five tool calls. Only finish() reaches the transcript.
const { results } = await find_model({
  capability: "text_to_image",
  query: "flux schnell"
});

const beats = ["establishing wide", "hands on the console", "cut to black"];
const runs = await Promise.all(
  beats.map((prompt) =>
    run_workflow({
      workflow_id: "wf_storyboard",
      params: { prompt, model: results[0].ref }
    })
  )
);

const stills = runs.map((r) => r.outputs.image?.[0]).filter(Boolean);
await thread_memory_save({
  content: "Storyboard stills for the console scene",
  resources: stills.map((uri) => ({ type: "asset", uri }))
});

finish({ shots: stills.length, missing: beats.length - stills.length });

Five tool calls, one action, one observation. The payload never leaves the guest โ€” only the reduction does.

@nodetool-ai/sandbox-nodetool/

33 namespaces, 208 tools. Only what an action imports gets mounted, so you pay for one module's dependency cone rather than the whole registry.

workflowsnodesmodelsagentsassetsmediajobscollectionswebmemorythreadsdocumentsscriptsstoryboardstimelinessketchesmodel3dentitiesappsjs-scriptscodeflowpackssettingsfilesemailgoogleserpapiapifycostsstylesharedui

Every capability resolves against the run's own user id, and "missing" and "not yours" are the same answer, so a run cannot probe for ids.

Token savings, by construction

The data stays in the guest isolate. You do not pay to pass giant JSON arrays back and forth to the model โ€” only the reduction crosses into its context. The model sees execute_code({ code }) and nothing else.

Zero new privileges

An agent gets the same limits and the same toolbelt as a developer writing a Code node. Every imported function is a tool the model could have called directly; an off-allowlist import stops the action before the guest starts, and third-party packs need session consent.

Fully observable

Every call inside an action still surfaces to the host as a tool_call_update, so composition does not become opacity. Tool calls are hard-capped at 50 per action, which stops a runaway loop.

Your code and the agent's are the same program

A Code node body is code a person saved. An action is code the model just wrote. The only difference is what the host granted each one: the packs a session consented to, the secret names in scope, the tools on the belt. Engine, limits, marshaling and imports are one implementation, so what you test by hand is what the agent gets at runtime.

Security and limits

Locked down by default

Hard ceilings: 64 MB guest heap, 30 s of CPU, a 512 KB call stack. A caller can tighten any limit or raise it within bounds. Nobody can switch a protection off, and nothing inside the guest can raise its own. A Code node and an agent action start from the same numbers.

Defaults and ceilings

LimitDefaultCeiling
Execution time30 sinterrupt handler on a CPU budget
Guest heap64 MB512 MB
Call stack512 KB8 MB
Fetch calls20 per run100
Fetch body1 MB50 MB
Fetch timeout15 s120 s
Redirect hops5re-checked at every hop
Output size100 KB10 MB
Media handles256 MB per runtotal encoded payload
Tool calls per action50stops a runaway loop

No dynamic code generation

No eval, no Function constructor, and dynamic import() is blocked outright. Enforcement sits in the module normalizer, not the loader โ€” QuickJS serves a cached module without ever consulting the loader.

Scoped secrets, and no setter

A script declares the secrets it needs up front and the bridge refuses every other name, so a node that talks to one service cannot read another's credentials. Writing one goes through the user's own client: request_secret takes a name and a reason, never a value. The credential never enters the guest, the websocket frame, or an LLM context.

SSRF guard, on every hop

The built-in fetch blocks loopback, link-local and private ranges, including IPv6 forms and IPv4-mapped addresses, and validates every single redirect hop. The switch that lifts it is host-set only, so guest code cannot enable it for itself.

Strict workspace containment

workspace.read, write and the rest are jailed to the run's root directory, with symlinks resolved and the real path re-checked immediately before every operation. Widening it to the host filesystem is a host-set switch that defaults off.

Hard abort, suspendable clock

Once the abort signal fires, every bridge call after it fails fast and the guest unwinds. Time parked waiting on a human approval is credited back, so a prompt nobody answers does not kill the program that asked.

The developer loop

You don't need a browser to build

Everything is drivable from the CLI, and a file target needs no database. The three verbs an agent reaches as validate_code, run_code and test_code are the ones you run at the prompt.

The script

// A JS script: a body plus declared ports, secrets, a timeout,
// and saved test cases. Callable from a mini app, a Code node,
// an agent, or another script.
let sum = 0;
for await (const n of stream("numbers")) {
  sum += n;
  await emit("running", sum);
}
await output("total", sum);

Scripts compose inside their own envelope: declared secrets intersected with the caller's allowance, own timeout, own imports. Depth is capped at 4 with a script-id chain, so a cycle fails the call and names it.

The loop

# Static. Syntax, imports against the installed catalog, undefined
# names, an undeclared inputs.* read, an output no return path sets.
nodetool jsscript validate ./running-total.json

# Once, in the real sandbox, with stream items staged by handle.
nodetool jsscript run ./running-total.json \
  --input-streams '{"numbers":[1,2,3]}'

# The document's own saved cases. Non-zero exit on any failure.
nodetool jsscript test ./running-total.json --json

# A whole graph, before a run pays for the half that works.
nodetool validate my-workflow.json
nodetool debug my-workflow.json --watch

Static checks return in well under a second. That is what makes them a pre-flight instead of an afterthought.

Sub-second static analysis

jsscript validate runs an AST check: unresolvable imports, undefined names, a bare read of a node input, a declared output nothing writes. Milliseconds, before you pay for a run. One implementation serves the CLI, the authoring planner and the editor, so a body cannot pass in one surface and fail in another.

Headless testing

jsscript test runs the document's own saved cases and exits non-zero on any failure. No browser, no database โ€” a file target is enough, which is what makes it usable from a pre-commit hook or CI.

Diff-based watch mode

debug --watch does not spam the terminal. On save it prints only what moved: verdict transitions, issues that appeared and resolved, token and cost movement. The edit-verify loop reads as a changelog.

Extend and deploy

The rest of the runtime is yours too

Sandboxed code is where you spend most of your time. Under it sits an actor-model kernel, a node SDK, an HTTP and WebSocket API, and a container image you can run on your own hardware.

Custom nodes in 15 lines

Extend BaseNode, decorate the properties, implement process(). Register the package and the node shows up in the visual canvas, in the DSL codegen, and as a sandbox-flow callable. One definition, every surface.

Native MCP support

The whole toolbelt speaks Model Context Protocol. nodetool mcp install wires up a CLI agent in one command; the .mcpb bundle installs into Claude Desktop by drag-and-drop and hot-attaches when the server appears. A deployed server is reached at /mcp with a token minted in settings.

Self-host anywhere

AGPL-3.0. The deploy unit is one self-contained container image with the frontend and the example workflows baked in: Docker Compose for your own box, or the deploy tooling for SSH, RunPod, GCP and Supabase. Your keys, your files, providers billed directly.

A node in 15 lines

import { BaseNode, prop } from "@nodetool-ai/node-sdk";

export class SentimentNode extends BaseNode {
  static readonly nodeType = "my.text.Sentiment";
  static readonly title = "Sentiment";
  static readonly description = "Score text sentiment from -1 to 1.";
  static readonly metadataOutputTypes = { score: "float" };

  @prop({ type: "str", default: "" })
  declare text: string;

  async process(): Promise<{ score: number }> {
    return { score: await analyze(this.text) };
  }
}

From a checkout

git clone https://github.com/nodetool-ai/nodetool
cd nodetool
nvm use                  # Node 22.22.1, from .nvmrc
npm install
npm run build:packages
npm run dev:nodetool -- serve   # HTTP + WebSocket on :7777

The @nodetool-ai packages are not on npm yet, so host-side imports resolve from a source checkout. The sandbox specifiers elsewhere on this page are a different thing โ€” they resolve inside the guest, and every pack listed above ships with the app.

Made with working creatives

NodeTool is open source under AGPL-3.0. Star the project on GitHub, join us on Discord, and share workflows with the artists, motion designers, and studios already using it on real jobs.

Get in
touch

Questions, bug reports, feature requests โ€” we answer.

General Inquiries

Say hi or tell us what you need.

The Team

Email the developers directly.